diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt index b2f4eb2..ec52f0f 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ui/GlassesScreen.kt @@ -39,6 +39,7 @@ import pw.binom.rayneovm.glasses.vm.GlassesScreenState fun GlassesScreen( controller: VmGlassesController, modifier: Modifier = Modifier, + onRequestShizukuPermission: (() -> Unit)? = null, ) { val state by controller.state.collectAsState() val frameSource = remember { mutableStateOf(NoOpFrameSource()) } @@ -55,8 +56,9 @@ fun GlassesScreen( is GlassesScreenState.Starting -> StatusOverlay("Подготавливаем очки...") is GlassesScreenState.ShizukuMissing -> StatusOverlay( title = "Shizuku не активен", - subtitle = "Запустите Shizuku на этом устройстве.\n" + - "Без него приложение не сможет создать виртуальный экран." + subtitle = "Без Shizuku приложение не может создать виртуальный экран.\n" + + "Тапните здесь, чтобы дать разрешение.", + onClick = onRequestShizukuPermission, ) is GlassesScreenState.NoConnection -> StatusOverlay( title = "Нет связи с телефоном", diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt index ea8c595..d540354 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -15,7 +15,10 @@ 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.AppEntry +import pw.binom.rayneovm.shared.AppListResponse import pw.binom.rayneovm.shared.AppLaunchedAck +import pw.binom.rayneovm.shared.GetAppList import pw.binom.rayneovm.shared.GlassesStatus import pw.binom.rayneovm.shared.Pong import pw.binom.rayneovm.shared.SetAudioMode @@ -116,6 +119,9 @@ class VmPhoneController( is AppLaunchedAck -> { _appLaunched.value = msg } + is AppListResponse -> { + _appsOnGlasses.value = msg.apps + } is SetAudioMode -> { // Аудио режим — отдельный поток, не контролируется этим контроллером напрямую. // AudioCaptureService на очках стартует/останавливается. @@ -132,9 +138,17 @@ class VmPhoneController( private val _appLaunched = MutableStateFlow(null) val appLaunched: StateFlow = _appLaunched.asStateFlow() + private val _appsOnGlasses = MutableStateFlow>(emptyList()) + val appsOnGlasses: StateFlow> = _appsOnGlasses.asStateFlow() + fun clearAppCrashed() { _appCrashed.value = null } fun clearAppLaunched() { _appLaunched.value = null } + /** Просит очки прислать список установленных приложений. Игнорирует, если не подключены. */ + suspend fun requestAppList() { + send(GetAppList) + } + /** Отправляет сообщение очкам. */ suspend fun send(msg: VmMessage) { val t = currentTransport ?: return diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/AppsListScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/AppsListScreen.kt index ce311f6..76c6044 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/AppsListScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/AppsListScreen.kt @@ -7,25 +7,31 @@ 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.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip 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.AppEntry import pw.binom.rayneovm.shared.LaunchAppCommand /** - * Список приложений, которые можно запустить на очках через VirtualDisplay. - * - * Сейчас — только Zona. В будущем: реестр в shared/AppProfiles.kt. + * Список приложений, установленных на ОЧКАХ. Тап → запуск на очках. + * Список приходит динамически по WebSocket (VmMessage.GetAppList → AppListResponse). */ @Composable fun AppsListScreen( @@ -33,73 +39,83 @@ fun AppsListScreen( onAppClick: (String) -> Unit = {}, ) { val glasses by controller.glassesStatus.collectAsState() + val apps by controller.appsOnGlasses.collectAsState() val isRunning = glasses?.running == true val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() - val apps = listOf( - AppEntry("free.zona", "Zona", "Кинотеатр Zona"), - ) + LaunchedEffect(Unit) { + controller.requestAppList() + } Column( modifier = Modifier .fillMaxSize() .background(Color.Black) + .verticalScroll(rememberScrollState()) .padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Приложения", + text = "Приложения на очках", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold, ) Text( - text = "Тапните, чтобы запустить на очках", + text = "Тап — запуск на очках. На телефоне сами приложения не запускаются.", color = Color(0xFFB0B0B0), - fontSize = 14.sp, + fontSize = 13.sp, ) + if (apps.isEmpty()) { + Text( + text = "Нет связи с очками или список пуст.", + color = Color(0xFF888888), + fontSize = 14.sp, + modifier = Modifier.padding(top = 24.dp), + ) + } + 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, - ) - } + AppCard(app, isRunning && glasses?.currentApp == app.packageName) { + coroutineScope.launch { + controller.send(LaunchAppCommand(packageName = app.packageName)) } + onAppClick(app.packageName) } } } } -private data class AppEntry( - val packageName: String, - val name: String, - val description: String, -) +@Composable +private fun AppCard(app: AppEntry, running: Boolean, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = androidx.compose.material3.CardDefaults.cardColors( + containerColor = Color(0xFF1A1A1A), + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = app.displayName, + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = app.packageName, + color = Color(0xFFB0B0B0), + fontSize = 12.sp, + ) + if (running) { + Text( + text = "● запущено", + color = Color(0xFF4CAF50), + fontSize = 12.sp, + ) + } + } + } +} diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt index 36d852d..121421e 100644 --- a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt @@ -74,6 +74,22 @@ enum class AudioMode { ON_GLASSES, ON_PHONE } @SerialName("shutdown") data object Shutdown : VmMessage +@Serializable +@SerialName("get_app_list") +data object GetAppList : VmMessage + +@Serializable +data class AppEntry( + val packageName: String, + val displayName: String, +) : VmMessage + +@Serializable +@SerialName("app_list") +data class AppListResponse( + val apps: List, +) : VmMessage + // ===================================================================== // Glasses → Phone // =====================================================================