This commit is contained in:
2026-09-25 13:24:56 +03:00
parent 45066a65a1
commit 987f9e5547
47 changed files with 4998 additions and 1 deletions
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
<application
android:name=".PhoneVmApp"
android:label="@string/app_name"
android:allowBackup="false"
android:theme="@style/Theme.RayNeoVm"
android:supportsRtl="true"
tools:targetApi="36">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:launchMode="singleTask"
android:theme="@style/Theme.RayNeoVm">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Foreground service для фонового воспроизведения аудио -->
<service
android:name=".audio.AudioPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
@@ -0,0 +1,59 @@
package pw.binom.rayneovm.phone
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.phone.ui.HomeScreen
/**
* Single-Activity приложения. Compose host + Navigation.
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val controller = VmPhoneController.fromContext(this)
val mercuryController = MercuryController.fromContext(this)
setContent {
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
val allGranted = result.values.all { it }
mercuryController.onPermissionsResult(allGranted)
}
CompositionLocalProvider(
VmPhoneController.Local provides controller,
MercuryController.Local provides mercuryController,
) {
MaterialTheme(colorScheme = darkColorScheme()) {
androidx.compose.foundation.layout.Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
HomeScreen(
controller = controller,
mercuryController = mercuryController,
onRequestMercuryPermissions = {
permissionLauncher.launch(mercuryController.requiredPermissions)
},
)
}
}
}
}
}
}
@@ -0,0 +1,41 @@
package pw.binom.rayneovm.phone
import android.app.Application
import android.util.Log
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
import pw.binom.rayneovm.phone.protocol.ControllerProvider
import pw.binom.rayneovm.phone.protocol.VmPhoneController
/**
* Application-класс PhoneApp.
*
* При старте подготавливает контроллер [VmPhoneController] (auto-reconnect к очкам через mDNS)
* и [MercuryController] (второй канал — RayNeo Mercury BLE).
*/
class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider {
private val controller: VmPhoneController by lazy { VmPhoneController(this) }
private val mercuryController: MercuryController by lazy { MercuryController(this) }
override fun onCreate() {
super.onCreate()
Log.i(TAG, "PhoneVmApp onCreate, uid=${android.os.Process.myUid()}")
// Запуск отложен до первого обращения — в lazy.
controller.start()
mercuryController.refresh()
}
override fun onTerminate() {
super.onTerminate()
controller.stop()
mercuryController.close()
}
override fun provideController(): VmPhoneController = controller
override fun provideMercuryController(): MercuryController = mercuryController
companion object {
private const val TAG = "PhoneVmApp"
}
}
@@ -0,0 +1,108 @@
package pw.binom.rayneovm.phone.audio
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.net.wifi.WifiManager
import android.os.PowerManager
import android.util.Log
import pw.binom.mercury.transport.ByteTransport
import pw.binom.mercury.transport.Transports
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Менеджер воспроизведения аудио с очков на телефон (Audio mode 2).
*
* Держит AudioTrack + WiFi/Wake lock + WebSocket-клиент, качающий PCM с очков.
*
* Вызывается из [pw.binom.rayneovm.phone.protocol.VmPhoneController] при получении
* [pw.binom.rayneovm.shared.SetAudioMode] с режимом ON_PHONE.
*/
class AudioPlaybackManager(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var track: AudioTrack? = null
private var transport: ByteTransport? = null
private var streamJob: Job? = null
private var wifiLock: WifiManager.WifiLock? = null
private var wakeLock: PowerManager.WakeLock? = null
fun start(glassesHost: String) {
if (track != null) return // уже запущено
val sampleRate = SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_OUT_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding)
val bufferSize = minBuffer * 4
track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(encoding)
.setChannelMask(channelMask)
.build()
)
.setBufferSizeInBytes(bufferSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
track!!.play()
Log.i(TAG, "AudioTrack started: $sampleRate Hz, buffer=$bufferSize")
// WiFi + Wake lock
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "VmAudioPlaybackLock").apply { acquire() }
val pm = context.applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "VmAudioPlaybackWakeLock").apply {
acquire(60 * 60 * 1000L)
}
// Подключаемся к очкам по тому же ws://host:port/vm-audio
val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio"
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
streamJob = scope.launch {
try {
transport!!.connect(
onMessage = { pcm ->
val t = track ?: return@connect
t.write(pcm, 0, pcm.size)
},
onConnected = { Log.i(TAG, "audio connected to $wsUrl") },
onDisconnected = { Log.w(TAG, "audio disconnected") },
)
} catch (t: Throwable) {
Log.e(TAG, "audio transport failed", t)
}
}
}
fun stop() {
streamJob?.cancel(); streamJob = null
transport?.close(); transport = null
track?.stop(); track?.release(); track = null
wifiLock?.release(); wifiLock = null
wakeLock?.release(); wakeLock = null
Log.i(TAG, "AudioPlaybackManager stopped")
}
companion object {
private const val TAG = "AudioPlaybackMgr"
const val SAMPLE_RATE = 44100
const val GLASSES_PORT = 8080
}
}
@@ -0,0 +1,51 @@
package pw.binom.rayneovm.phone.audio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import pw.binom.rayneovm.phone.MainActivity
/**
* Stub: реальный foreground service — в [AudioPlaybackManager].
* Класс оставлен для совместимости с AndroidManifest <service> декларацией и не вызывается.
*/
class AudioPlaybackService : Service() {
override fun onBind(intent: Intent): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForeground(NOTIFICATION_ID, buildNotification())
return START_NOT_STICKY
}
private fun buildNotification(): Notification {
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, "vm_audio_playback")
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("RayNeo VM")
.setContentText("Audio playback активен")
.setContentIntent(openApp)
.setOngoing(true)
.build()
}
override fun onDestroy() {
super.onDestroy()
}
companion object {
private const val NOTIFICATION_ID = 300
}
}
@@ -0,0 +1,271 @@
package pw.binom.rayneovm.phone.mercury
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.RayNeoMercury
/**
* Контроллер второго канала связи с очками — RayNeo Mercury BLE.
*
* В отличие от [pw.binom.rayneovm.phone.protocol.VmPhoneController], который работает
* по нашему собственному WebSocket-протоколу поверх WiFi/mDNS, этот контроллер
* прикидывается штатным приложением RayNeo Mercury и говорит с очками по их
* закрытому BLE-протоколу (см. rayneo-mercury/library).
*
* Это bootstrap-канал (правило #4746): нужен, чтобы запустить наше приложение
* на очках через `appMarketLaunch(pkg)`, если mDNS не нашёл WebSocket-сервис.
*/
class MercuryController(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mercury = RayNeoMercury(context, externalScope = scope)
private val _state = MutableStateFlow<MercuryState>(MercuryState.PermissionsMissing)
val state: StateFlow<MercuryState> = _state.asStateFlow()
private var lastScanResults: List<DiscoveredGlasses> = emptyList()
/** Разрешения, которые нужно запросить для работы с BLE на этом API. */
val requiredPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
init {
observeEvents()
refreshPermissionState()
}
/** Перепроверить состояние Bluetooth-адаптера и разрешений. */
fun refresh() {
refreshPermissionState()
}
/** UI вызывает после системного диалога разрешений. */
fun onPermissionsResult(granted: Boolean) {
if (granted) {
if (isBluetoothEnabled()) {
_state.value = MercuryState.Disconnected()
} else {
_state.value = MercuryState.BluetoothOff
}
} else {
_state.value = MercuryState.PermissionsMissing
}
}
/**
* Сканировать ближайшие очки (RayNeo Mercury advertisement).
* По окончании — переход в [MercuryState.Disconnected] с заполненным
* `lastScanResults`, или в [MercuryState.Scanning] во время скана.
*/
fun scan() {
if (!hasPermissions()) {
_state.value = MercuryState.PermissionsMissing
return
}
if (!isBluetoothEnabled()) {
_state.value = MercuryState.BluetoothOff
return
}
_state.value = MercuryState.Scanning(emptyList())
scope.launch {
val results = mutableListOf<DiscoveredGlasses>()
try {
mercury.scan()?.collect { glasses ->
if (results.none { it.address == glasses.address }) {
results += glasses
_state.value = MercuryState.Scanning(results.toList())
}
} ?: run {
_state.value = MercuryState.Disconnected(
message = "Bluetooth-адаптер недоступен",
)
return@launch
}
// Скан завершился без явной отмены — фиксируем результат
lastScanResults = results
if (results.isEmpty()) {
_state.value = MercuryState.Disconnected(
message = "Очки не найдены. Включите их и поднесите ближе.",
)
} else {
_state.value = MercuryState.Disconnected(
message = "Найдено устройств: ${results.size}. Выберите для подключения.",
)
}
} catch (t: Throwable) {
_state.value = MercuryState.Disconnected(message = "Ошибка скана: ${t.message}")
}
}
}
/**
* Подключиться к найденному устройству. Если оно ещё не спарено — сначала
* создаём OS-level bond, потом открываем GATT-сессию.
*/
fun connect(glasses: DiscoveredGlasses) {
if (!hasPermissions()) {
_state.value = MercuryState.PermissionsMissing
return
}
_state.value = MercuryState.Connecting(glasses)
scope.launch {
try {
val paired = mercury.pair(glasses)
if (paired is pw.binom.mercury.pair.PairManager.PairingResult.Failed) {
_state.value = MercuryState.Disconnected(
message = "Сопряжение не удалось: ${paired.reason}",
)
return@launch
}
val ok = mercury.connect(glasses)
if (!ok) {
_state.value = MercuryState.Disconnected(
message = "Не удалось открыть GATT-сессию",
)
return@launch
}
_state.value = MercuryState.Connected(glasses, batteryPercent = null)
// Запрашиваем актуальный статус, чтобы получить battery/wifi
mercury.pullGeneralStatus()
} catch (t: Throwable) {
_state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}")
}
}
}
fun disconnect() {
scope.launch { mercury.disconnect() }
_state.value = MercuryState.Disconnected()
}
/**
* Запустить приложение на очках через штатный механизм Mercury
* (appMarketLaunch). Требует активной BLE-сессии — иначе вернёт false.
*/
suspend fun launchGlassesApp(packageName: String): Boolean {
if (_state.value !is MercuryState.Connected) return false
return mercury.appMarketLaunch(packageName)
}
/** Доступ к raw event-flow библиотеки (debug/диагностика). */
val events get() = mercury.events
private fun refreshPermissionState() {
_state.value = when {
!hasPermissions() -> MercuryState.PermissionsMissing
!isBluetoothEnabled() -> MercuryState.BluetoothOff
else -> if (_state.value is MercuryState.Connected) _state.value else MercuryState.Disconnected()
}
}
private fun hasPermissions(): Boolean = requiredPermissions.all {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
private fun isBluetoothEnabled(): Boolean {
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
return try {
adapter?.isEnabled == true
} catch (_: SecurityException) {
false
}
}
private fun observeEvents() {
scope.launch {
mercury.events.collect { event ->
when (event) {
is RayNeoMercury.Event.Connected -> {
val cur = _state.value
if (cur is MercuryState.Connecting) {
_state.value = MercuryState.Connected(cur.device, batteryPercent = null)
}
}
is RayNeoMercury.Event.Disconnected -> {
val cur = _state.value
if (cur is MercuryState.Connected) {
_state.value = MercuryState.Disconnected(
message = "Связь потеряна: ${event.cause}",
)
}
}
is RayNeoMercury.Event.GeneralStatusUpdate -> {
val cur = _state.value
if (cur is MercuryState.Connected) {
_state.value = cur.copy(
batteryPercent = event.status.battery,
)
}
}
else -> Unit
}
}
}
}
fun close() {
scope.coroutineContext[Job]?.cancel()
mercury.close()
}
companion object {
val Local = staticCompositionLocalOf<MercuryController> {
error("MercuryController not provided")
}
fun fromContext(context: Context): MercuryController =
(context.applicationContext as? MercuryControllerProvider)?.provideMercuryController()
?: error("Application must implement MercuryControllerProvider")
}
}
interface MercuryControllerProvider {
fun provideMercuryController(): MercuryController
}
/**
* Состояние второго канала (Mercury BLE). UI рендерит это как «Канал 2».
*/
sealed interface MercuryState {
/** Нет разрешений BLUETOOTH_SCAN / BLUETOOTH_CONNECT. */
data object PermissionsMissing : MercuryState
/** Разрешения есть, но Bluetooth выключен. */
data object BluetoothOff : MercuryState
/** Готовы к сканированию / подключению. */
data class Disconnected(val message: String? = null) : MercuryState
/** Идёт скан, [found] — то, что уже нашли. */
data class Scanning(val found: List<DiscoveredGlasses>) : MercuryState
/** Устанавливаем пару + GATT. */
data class Connecting(val device: DiscoveredGlasses) : MercuryState
/** GATT-сессия жива, очки отвечают. */
data class Connected(
val device: DiscoveredGlasses,
val batteryPercent: Int? = null,
) : MercuryState
}
@@ -0,0 +1,191 @@
package pw.binom.rayneovm.phone.protocol
import android.content.Context
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import pw.binom.mercury.transport.StringTransport
import pw.binom.mercury.transport.Transports
import pw.binom.rayneovm.phone.audio.AudioPlaybackManager
import pw.binom.rayneovm.shared.AppCrashedEvent
import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.VmMessage
import pw.binom.rayneovm.shared.VmProtocol
/**
* Контроллер состояния и связи с очками на стороне телефона.
*
* Запускает фоновый цикл авто-реконнекта к очкам через mDNS + WebSocket.
* Хранит текущее состояние связи и обрабатывает входящие/исходящие сообщения.
*/
class VmPhoneController(
@Suppress("unused") private val context: Context,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state = MutableStateFlow<ConnectionState>(ConnectionState.Searching)
val state: StateFlow<ConnectionState> = _state.asStateFlow()
private val _glassesStatus = MutableStateFlow<GlassesStatusInfo?>(null)
val glassesStatus: StateFlow<GlassesStatusInfo?> = _glassesStatus.asStateFlow()
private var currentTransport: StringTransport? = null
private val audioManager by lazy { AudioPlaybackManager(context) }
fun start() {
if (scope.coroutineContext[Job]?.children?.any { it.isActive } == true) return
scope.launch { reconnectLoop() }
}
fun stop() {
currentTransport?.close()
currentTransport = null
audioManager.stop()
scope.coroutineContext[Job]?.cancelChildren()
_state.value = ConnectionState.Disconnected
}
/**
* Цикл поиска очков через mDNS + подключения.
*
* При разрыве — exponential backoff.
*/
private suspend fun reconnectLoop() {
var attempt = 0
val backoffMs = listOf(1_000L, 2_000L, 4_000L, 8_000L, 16_000L, 30_000L)
while (true) {
try {
_state.value = ConnectionState.Searching
val scanner = pw.binom.mercury.transport.MdnsScanner(context, "_rayneo-vm._tcp")
// mDNS пытается 5с; если не вышло — TCP-фоллбек по подсети ещё ~5с.
// Порт 8080 (см. docs/SPEC.md:4752 и VmService.WS_PORT).
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080)
_state.value = ConnectionState.Connecting(service.host)
val url = "ws://${service.host}:${service.port}/vm"
val transport = Transports.wifi(url, autoReconnect = false)
currentTransport = transport
_state.value = ConnectionState.Connected(service.host)
attempt = 0
transport.connect(
onMessage = { json ->
val msg = try {
VmProtocol.decodeAny(json)
} catch (_: Throwable) { return@connect }
handleIncoming(msg)
},
onConnected = { /* уже выставлено в Connecting->Connected */ },
onDisconnected = {
currentTransport = null
_state.value = ConnectionState.Disconnected
},
)
} catch (t: Throwable) {
currentTransport = null
_state.value = ConnectionState.Disconnected
}
val delay = backoffMs[attempt.coerceAtMost(backoffMs.lastIndex)]
attempt++
kotlinx.coroutines.delay(delay)
}
}
private fun handleIncoming(msg: VmMessage) {
when (msg) {
is GlassesStatus -> {
_glassesStatus.value = GlassesStatusInfo(
batteryPercent = msg.batteryPercent,
wifiSsid = msg.wifiSsid,
currentApp = msg.currentApp,
running = msg.running,
)
}
is AppCrashedEvent -> {
_appCrashed.value = msg.packageName
}
is AppLaunchedAck -> {
_appLaunched.value = msg
}
is SetAudioMode -> {
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
// AudioCaptureService на очках стартует/останавливается.
// На телефоне режим управляется UI (toggle на главном экране).
}
is Pong -> { /* keepalive */ }
else -> { /* другие: событийные, форвардим */ }
}
}
private val _appCrashed = MutableStateFlow<String?>(null)
val appCrashed: StateFlow<String?> = _appCrashed.asStateFlow()
private val _appLaunched = MutableStateFlow<AppLaunchedAck?>(null)
val appLaunched: StateFlow<AppLaunchedAck?> = _appLaunched.asStateFlow()
fun clearAppCrashed() { _appCrashed.value = null }
fun clearAppLaunched() { _appLaunched.value = null }
/** Отправляет сообщение очкам. */
suspend fun send(msg: VmMessage) {
val t = currentTransport ?: return
try {
t.send(VmProtocol.encode(msg))
} catch (t: Throwable) {
// ignore — connection state управляется через reconnect loop
}
}
/**
* Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE.
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
*/
suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) {
send(SetAudioMode(mode))
when (mode) {
pw.binom.rayneovm.shared.AudioMode.ON_GLASSES -> audioManager.stop()
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
val host = (currentTransport as? pw.binom.mercury.transport.WifiTransport)?.let { null }
?: (_state.value as? ConnectionState.Connected)?.host
if (host != null) audioManager.start(host)
}
}
}
companion object {
val Local = staticCompositionLocalOf<VmPhoneController> {
error("VmPhoneController not provided")
}
fun fromContext(context: Context): VmPhoneController =
(context.applicationContext as? ControllerProvider)?.provideController()
?: error("Application must implement ControllerProvider")
}
}
interface ControllerProvider {
fun provideController(): VmPhoneController
}
sealed interface ConnectionState {
data object Searching : ConnectionState
data class Connecting(val host: String) : ConnectionState
data class Connected(val host: String) : ConnectionState
data object Disconnected : ConnectionState
}
data class GlassesStatusInfo(
val batteryPercent: Int?,
val wifiSsid: String?,
val currentApp: String?,
val running: Boolean,
)
@@ -0,0 +1,105 @@
package pw.binom.rayneovm.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.LaunchAppCommand
/**
* Список приложений, которые можно запустить на очках через VirtualDisplay.
*
* Сейчас — только Zona. В будущем: реестр в shared/AppProfiles.kt.
*/
@Composable
fun AppsListScreen(
controller: VmPhoneController,
onAppClick: (String) -> Unit = {},
) {
val glasses by controller.glassesStatus.collectAsState()
val isRunning = glasses?.running == true
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
val apps = listOf(
AppEntry("free.zona", "Zona", "Кинотеатр Zona"),
)
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Приложения",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = "Тапните, чтобы запустить на очках",
color = Color(0xFFB0B0B0),
fontSize = 14.sp,
)
apps.forEach { app ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable {
coroutineScope.launch {
controller.send(LaunchAppCommand(packageName = app.packageName))
}
onAppClick(app.packageName)
},
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = Color(0xFF1A1A1A),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = app.name,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = app.description,
color = Color(0xFFB0B0B0),
fontSize = 12.sp,
)
if (isRunning && glasses?.currentApp == app.packageName) {
Text(
text = "● запущено",
color = Color(0xFF4CAF50),
fontSize = 12.sp,
)
}
}
}
}
}
}
private data class AppEntry(
val packageName: String,
val name: String,
val description: String,
)
@@ -0,0 +1,263 @@
package pw.binom.rayneovm.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.mercury.DiscoveredGlasses
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryState
import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController
/**
* Экран «Очки»: два независимых канала связи + запуск нашего приложения.
*
* - **Канал 1 (WebSocket)** — наше приложение на очках подключается к нам по WiFi/mDNS.
* Это основной control-канал (тач, клавиши, команды).
* - **Канал 2 (Mercury BLE)** — мы прикидываемся штатным приложением RayNeo Mercury
* и общаемся с очками по их закрытому BLE-протоколу. Это bootstrap-канал:
* нужен, чтобы запустить наше приложение через `appMarketLaunch(pkg)`, когда
* очки ещё не подключены к нам по WebSocket.
*
* Кнопка «Запустить наше приложение на очках» работает только когда жив Канал 2.
*/
@Composable
fun GlassesStatusScreen(
controller: VmPhoneController,
mercuryController: MercuryController,
onRequestMercuryPermissions: () -> Unit = {},
) {
val connection by controller.state.collectAsState()
val glasses by controller.glassesStatus.collectAsState()
val mercury by mercuryController.state.collectAsState()
val scope = rememberCoroutineScope()
val mercuryConnected = mercury is MercuryState.Connected
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "Очки",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
)
Channel1WebSocketSection(connection = connection, glasses = glasses)
HorizontalDivider(color = Color(0xFF2A2A2A))
Channel2MercurySection(
mercury = mercury,
onRequestPermissions = onRequestMercuryPermissions,
onScan = { mercuryController.scan() },
onConnect = { mercuryController.connect(it) },
onDisconnect = { mercuryController.disconnect() },
)
Spacer(modifier = Modifier.height(8.dp))
LaunchGlassesAppButton(
enabled = mercuryConnected,
onClick = {
scope.launch {
mercuryController.launchGlassesApp(GLASSES_PACKAGE)
}
},
)
}
}
private const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
@Composable
private fun Channel1WebSocketSection(
connection: ConnectionState,
glasses: pw.binom.rayneovm.phone.protocol.GlassesStatusInfo?,
) {
SectionCard(title = "Канал 1 — наше приложение (WebSocket)") {
StatusRow("Связь", connection.label)
when (connection) {
is ConnectionState.Connected -> glasses?.let { g ->
StatusRow("Аккумулятор", g.batteryPercent?.let { "$it%" } ?: "—")
StatusRow("WiFi", g.wifiSsid ?: "—")
StatusRow("Запущено", g.currentApp ?: "—")
StatusRow("Активно", if (g.running) "да" else "нет")
} ?: run {
Hint("Ожидаем статус от очков…")
}
else -> Hint(
when (connection) {
is ConnectionState.Searching -> "Ищем очки в сети…"
is ConnectionState.Connecting -> "Подключаемся к ${connection.host}…"
is ConnectionState.Disconnected ->
"Нет связи. Проверьте, что очки и телефон в одной WiFi-сети."
}
)
}
}
}
@Composable
private fun Channel2MercurySection(
mercury: MercuryState,
onRequestPermissions: () -> Unit,
onScan: () -> Unit,
onConnect: (DiscoveredGlasses) -> Unit,
onDisconnect: () -> Unit,
) {
SectionCard(title = "Канал 2 — очки (Mercury BLE)") {
when (mercury) {
is MercuryState.PermissionsMissing -> {
Hint("Нужны разрешения Bluetooth (BLUETOOTH_SCAN, BLUETOOTH_CONNECT).")
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onRequestPermissions) { Text("Запросить разрешения") }
}
is MercuryState.BluetoothOff -> {
Hint("Bluetooth выключен. Включите его в системных настройках.")
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = onScan) { Text("Проверить ещё раз") }
}
is MercuryState.Disconnected -> {
StatusRow("Связь", "нет связи")
mercury.message?.let { Hint(it) }
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onScan) { Text("Сканировать") }
}
is MercuryState.Scanning -> {
StatusRow("Связь", "поиск… (найдено ${mercury.found.size})")
Spacer(modifier = Modifier.height(8.dp))
if (mercury.found.isNotEmpty()) {
Text("Найденные устройства:", color = Color(0xFFB0B0B0), fontSize = 12.sp)
mercury.found.forEach { g ->
DeviceRow(g, onClick = { onConnect(g) })
}
} else {
Hint("Идёт скан, подождите…")
}
}
is MercuryState.Connecting -> {
StatusRow("Связь", "сопрягаем ${mercury.device.name ?: mercury.device.address}…")
Hint("Если очки запрашивают PIN — подтвердите на них.")
}
is MercuryState.Connected -> {
StatusRow("Связь", "подключено (${mercury.device.name ?: mercury.device.address})")
StatusRow("Адрес", mercury.device.address)
StatusRow("Аккумулятор", mercury.batteryPercent?.let { "$it%" } ?: "—")
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = onDisconnect) { Text("Отключить") }
}
}
}
}
@Composable
private fun LaunchGlassesAppButton(enabled: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF1565C0),
disabledContainerColor = Color(0xFF2A2A2A),
),
) {
Text(if (enabled) "Запустить наше приложение на очках" else "Запустить наше приложение на очках (нужен Канал 2)")
}
}
@Composable
private fun SectionCard(title: String, content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0xFF141414)),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = title,
color = Color(0xFF8AB4F8),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
content()
}
}
}
@Composable
private fun DeviceRow(glasses: DiscoveredGlasses, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(glasses.name ?: "—", color = Color.White, fontSize = 14.sp)
Text(glasses.address, color = Color(0xFFB0B0B0), fontSize = 11.sp)
}
Button(onClick = onClick) { Text("Подключить") }
}
}
@Composable
private fun StatusRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, color = Color(0xFFB0B0B0), fontSize = 14.sp)
Text(text = value, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
@Composable
private fun Hint(text: String) {
Box(modifier = Modifier.padding(top = 4.dp)) {
Text(text = text, color = Color(0xFFB0B0B0), fontSize = 13.sp)
}
}
private val ConnectionState.label: String
get() = when (this) {
is ConnectionState.Searching -> "поиск…"
is ConnectionState.Connecting -> "подключение…"
is ConnectionState.Connected -> "подключено ($host)"
is ConnectionState.Disconnected -> "нет связи"
}
@@ -0,0 +1,75 @@
package pw.binom.rayneovm.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.protocol.VmPhoneController
/**
* Главный экран приложения: NavigationBar с двумя табами.
*
* - «Очки» — статус связи и состояние GlassesApp
* - «Приложения» — список приложений для запуска
*/
@Composable
fun HomeScreen(
controller: VmPhoneController,
mercuryController: MercuryController,
onRequestMercuryPermissions: () -> Unit = {},
) {
var selectedTab by remember { mutableStateOf(Tab.GLASSES) }
MaterialTheme(colorScheme = darkColorScheme()) {
Scaffold(
modifier = Modifier.fillMaxSize().background(Color.Black),
containerColor = Color.Black,
bottomBar = {
NavigationBar(containerColor = Color(0xFF101010)) {
Tab.entries.forEach { tab ->
NavigationBarItem(
selected = tab == selectedTab,
onClick = { selectedTab = tab },
icon = { Icon(tab.icon, contentDescription = tab.label) },
label = { Text(tab.label) },
)
}
}
}
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
when (selectedTab) {
Tab.GLASSES -> GlassesStatusScreen(
controller = controller,
mercuryController = mercuryController,
onRequestMercuryPermissions = onRequestMercuryPermissions,
)
Tab.APPS -> AppsListScreen(controller = controller)
}
}
}
}
}
private enum class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
GLASSES("Очки", Icons.Default.Settings),
APPS("Приложения", Icons.Default.Apps),
}
@@ -0,0 +1,107 @@
package pw.binom.rayneovm.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.TouchAction
import pw.binom.rayneovm.shared.TouchEvent
import pw.binom.rayneovm.shared.TouchType
import kotlin.math.roundToInt
/**
* Виртуальный тачпад: тапы/перетаскивания → [TouchEvent] на очки.
*
* Координаты внутри тачпада нормализуются в [0, targetWidth-1] × [0, targetHeight-1].
* Точка касания визуализируется кружком.
*/
@Composable
fun TouchpadScreen(
controller: VmPhoneController,
targetWidth: Int = 640,
targetHeight: Int = 480,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
var touchPoint by remember { mutableStateOf<Offset?>(null) }
Box(
modifier = modifier
.background(Color(0xFF101010))
.pointerInput(targetWidth, targetHeight) {
detectTapGestures(
onPress = { offset ->
val x = (offset.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1)
val y = (offset.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1)
touchPoint = offset
scope.launch {
controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.DOWN, x.toFloat(), y.toFloat()))))
try { tryAwaitRelease() } finally {
touchPoint = null
}
controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.UP, x.toFloat(), y.toFloat()))))
}
},
)
}
.pointerInput(targetWidth, targetHeight) {
detectDragGestures(
onDragStart = { offset ->
val x = (offset.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1)
val y = (offset.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1)
touchPoint = offset
scope.launch {
controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.DOWN, x.toFloat(), y.toFloat()))))
}
},
onDragEnd = {
touchPoint?.let { last ->
val x = (last.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1)
val y = (last.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1)
scope.launch {
controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.UP, x.toFloat(), y.toFloat()))))
}
}
touchPoint = null
},
onDragCancel = { touchPoint = null },
onDrag = { change, _ ->
val x = (change.position.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1)
val y = (change.position.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1)
touchPoint = change.position
scope.launch {
controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.MOVE, x.toFloat(), y.toFloat()))))
}
},
)
},
) {
touchPoint?.let { pos ->
Box(
modifier = Modifier
.offset { IntOffset((pos.x - 24.dp.toPx()).toInt(), (pos.y - 24.dp.toPx()).toInt()) }
.size(48.dp)
.clip(CircleShape)
.background(Color(0xFF80CBC4).copy(alpha = 0.6f))
)
}
}
}
@@ -0,0 +1,196 @@
package pw.binom.rayneovm.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.text.KeyboardOptions
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.LaunchAppCommand
/**
* Профиль Zona: тачпад + медиа-кнопки + клавиатура + назад.
*/
@Composable
fun ZonaProfile(
controller: VmPhoneController,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val glasses by controller.glassesStatus.collectAsState()
val appCrashed by controller.appCrashed.collectAsState()
var showKeyboard by remember { mutableStateOf(false) }
// Если приложение упало — сразу назад (если хочется — можно показать Snackbar)
androidx.compose.runtime.LaunchedEffect(appCrashed) {
if (appCrashed != null) {
// TODO: показать Snackbar и попап обратно в список
controller.clearAppCrashed()
}
}
Column(modifier = modifier.fillMaxSize().background(Color.Black)) {
// Верхняя панель: назад + название + статус
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { /* TODO: navigateBack */ }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Назад", tint = Color.White)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Zona",
color = Color.White,
fontSize = 18.sp,
)
Spacer(modifier = Modifier.weight(1f))
if (glasses?.currentApp == "free.zona") {
Text(
text = if (glasses?.running == true) "●" else "○",
color = if (glasses?.running == true) Color(0xFF4CAF50) else Color(0xFFB0B0B0),
fontSize = 20.sp,
)
}
}
// Тачпад (занимает большую часть экрана)
TouchpadScreen(
controller = controller,
modifier = Modifier.weight(1f).fillMaxWidth().padding(8.dp),
)
// Нижняя панель: медиа-кнопки + клавиатура
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = {
scope.launch { controller.send(VmKeyEvent(keyCode = 89, action = 1)) } // KEYCODE_MEDIA_REWIND
}) { Icon(Icons.Default.SkipPrevious, contentDescription = "Назад", tint = Color.White) }
IconButton(onClick = {
scope.launch { controller.send(VmKeyEvent(keyCode = 85, action = 1)) } // KEYCODE_MEDIA_PLAY_PAUSE
}) { Icon(Icons.Default.PlayArrow, contentDescription = "Play/Pause", tint = Color.White) }
IconButton(onClick = {
scope.launch { controller.send(VmKeyEvent(keyCode = 90, action = 1)) } // KEYCODE_MEDIA_FAST_FORWARD
}) { Icon(Icons.Default.SkipNext, contentDescription = "Вперёд", tint = Color.White) }
IconButton(onClick = {
scope.launch { controller.send(VmKeyEvent(keyCode = 4, action = 1)) } // KEYCODE_BACK
}) { Icon(Icons.Default.ArrowBack, contentDescription = "Назад (в приложении)", tint = Color.White) }
IconButton(onClick = { showKeyboard = true }) {
Icon(Icons.Default.Keyboard, contentDescription = "Клавиатура", tint = Color.White)
}
}
}
if (showKeyboard) {
KeyboardDialog(
onDismiss = { showKeyboard = false },
onSend = { text ->
scope.launch {
text.forEach { ch ->
val keyCode = keyCodeForChar(ch)
if (keyCode != -1) {
controller.send(VmKeyEvent(keyCode = keyCode, action = 1))
}
}
}
showKeyboard = false
},
)
}
}
@Composable
private fun KeyboardDialog(
onDismiss: () -> Unit,
onSend: (String) -> Unit,
) {
var input by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Ввод текста") },
text = {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done,
),
)
},
confirmButton = {
TextButton(onClick = { onSend(input) }) { Text("Отправить") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Отмена") }
},
)
}
/**
* ASCII char → Android keyCode. Простая таблица, без юникода и без спец.клавиш.
* Возвращает -1 для символов, которые нельзя передать через KEYCODE.
*/
private fun keyCodeForChar(ch: Char): Int {
return when (ch) {
in 'a'..'z' -> android.view.KeyEvent.KEYCODE_A + (ch - 'a')
in 'A'..'Z' -> android.view.KeyEvent.KEYCODE_A + (ch - 'A')
in '0'..'9' -> android.view.KeyEvent.KEYCODE_0 + (ch - '0')
' ' -> android.view.KeyEvent.KEYCODE_SPACE
'\n' -> android.view.KeyEvent.KEYCODE_ENTER
'.' -> android.view.KeyEvent.KEYCODE_PERIOD
',' -> android.view.KeyEvent.KEYCODE_COMMA
'!' -> android.view.KeyEvent.KEYCODE_1 // shift+1 — отдаём как KEYCODE_1 + shift, но тут упрощённо
'?' -> android.view.KeyEvent.KEYCODE_SLASH
else -> -1
}
}
@Composable
private fun Spacer(modifier: Modifier) {
androidx.compose.foundation.layout.Spacer(modifier = modifier)
}
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">RayNeo VM</string>
</resources>
@@ -0,0 +1,7 @@
<resources>
<style name="Theme.RayNeoVm" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>