diff --git a/app/src/main/app/shell/Eyeglasses2Icon.kt b/app/src/main/app/shell/Eyeglasses2Icon.kt new file mode 100644 index 000000000..7d37777a2 --- /dev/null +++ b/app/src/main/app/shell/Eyeglasses2Icon.kt @@ -0,0 +1,33 @@ +package com.winlator.cmod.app.shell + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.dp + +// Material Symbols "eyeglasses_2" (not shipped by compose material-icons). Its 960x960 viewBox is +// offset by -960 in y, so the path lives in a group translated back down by 960. +private const val EYEGLASSES_2_PATH = + "M218-320q-42 0-75.5-27T100-416L71-550l-44 3-7-80q78-7 133.5-10t99.5-3q65 0 105 6t72 21q14 7 " + + "26.5 10t23.5 3q11 0 21.5-3t24.5-9q33-15 76-21.5t114-6.5q46 0 102 3t122 9l-7 79-43-3-30 137q-9 " + + "42-42 68.5T743-320h-89q-42 0-74-25.5T538-411l-27-107h-61l-27 107q-11 41-43 66t-73 25h-89Zm-40-112q3 " + + "14 14 23t25 9h89q14 0 25-8.5t14-21.5l31-121q-27-5-61-6.5t-62-1.5q-23 0-49.5.5T154-556l24 124Zm437 " + + "2q3 13 14 21.5t25 8.5h89q14 0 25-9t14-23l26-125q-20-1-46-1.5t-46-.5q-30 0-66.5 1.5T584-551l31 121Z" + +val Eyeglasses2Icon: ImageVector by lazy { + ImageVector.Builder( + name = "Eyeglasses2", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + addGroup(translationY = 960f) + addPath( + pathData = PathParser().parsePathString(EYEGLASSES_2_PATH).toNodes(), + fill = SolidColor(Color.Black), + ) + clearGroup() + }.build() +} diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 7614a7ded..51563de9a 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -1091,6 +1091,7 @@ class UnifiedActivity : if (maybeForwardFrontendLaunch()) return supportFragmentManager.registerFragmentLifecycleCallbacks(inputControlsFragmentTracker, true) + com.winlator.cmod.runtime.display.GlassesManager.init(this) bootstrapStartupState() maybeAutoSignInGoogleOnLaunch() @@ -2168,6 +2169,125 @@ class UnifiedActivity : ) } + @Composable + private fun GlassesSettingsSheet(onDismiss: () -> Unit) { + val gm = com.winlator.cmod.runtime.display.GlassesManager + val settings by gm.settings.collectAsState() + val brightnessMax = gm.brightnessMax() + val volumeMax = gm.volumeMax() + val brightness = if (settings.brightness < 0) brightnessMax else settings.brightness + val volume = if (settings.volume < 0) volumeMax else settings.volume + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + androidx.compose.material3.Surface( + shape = RoundedCornerShape(24.dp), + color = SurfaceDark, + modifier = Modifier.fillMaxWidth(0.82f), + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 22.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Eyeglasses2Icon, contentDescription = null, tint = Accent, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(10.dp)) + Text(gm.modelName(), color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold) + } + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + GlassesLabel(stringResource(R.string.glasses_panel_refresh)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(60, 90, 120).forEach { hz -> + val selected = settings.refreshHz == hz + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(11.dp)) + .background(if (selected) Accent else TextSecondary.copy(alpha = 0.12f)) + .clickable { gm.setRefreshHz(hz) } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text("$hz", color = if (selected) SurfaceDark else TextPrimary, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GlassesToggleTile(stringResource(R.string.glasses_panel_sunblock), + settings.sunblock, Modifier.weight(1f)) { gm.setSunblock(it) } + GlassesToggleTile(stringResource(R.string.session_drawer_output_3d), + settings.threeD, Modifier.weight(1f)) { gm.set3D(it) } + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(14.dp)) { + GlassesPercentSlider(stringResource(R.string.session_drawer_output_brightness), + brightness, brightnessMax) { gm.setBrightness(it) } + GlassesPercentSlider(stringResource(R.string.session_drawer_output_volume), + volume, volumeMax) { gm.setVolume(it) } + } + } + } + } + } + } + + @Composable + private fun GlassesLabel(text: String) { + Text(text, color = TextSecondary, fontSize = 13.sp, fontWeight = FontWeight.Medium) + } + + @Composable + private fun GlassesPercentSlider(label: String, level: Int, max: Int, onChange: (Int) -> Unit) { + val pct = if (max > 0) Math.round(level * 100f / max) else 0 + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + GlassesLabel(label) + Text("$pct%", color = Accent, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } + androidx.compose.material3.Slider( + value = level.toFloat(), + onValueChange = { onChange(it.roundToInt()) }, + valueRange = 0f..max.toFloat(), + steps = (max - 1).coerceAtLeast(0), + colors = androidx.compose.material3.SliderDefaults.colors( + thumbColor = Accent, + activeTrackColor = Accent, + inactiveTrackColor = TextSecondary.copy(alpha = 0.2f), + ), + ) + } + } + + @Composable + private fun GlassesToggleTile(label: String, checked: Boolean, modifier: Modifier = Modifier, onChange: (Boolean) -> Unit) { + Column( + modifier = modifier + .clip(RoundedCornerShape(13.dp)) + .background(if (checked) Accent.copy(alpha = 0.16f) else TextSecondary.copy(alpha = 0.08f)) + .clickable { onChange(!checked) } + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(label, color = TextPrimary, fontSize = 13.sp, fontWeight = FontWeight.Medium) + androidx.compose.material3.Switch( + checked = checked, + onCheckedChange = onChange, + colors = androidx.compose.material3.SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Accent, + ), + ) + } + } + @Composable private fun TopBar( tabs: List, @@ -2189,6 +2309,8 @@ class UnifiedActivity : val searchFocusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current val isDownloadsTab = tabs.getOrNull(selectedIdx)?.key == "downloads" + val glassesConnected by com.winlator.cmod.runtime.display.GlassesManager.connected.collectAsState() + var showGlassesPanel by remember { mutableStateOf(false) } LaunchedEffect(selectedIdx) { if (isSearchExpanded) { @@ -2428,6 +2550,21 @@ class UnifiedActivity : Spacer(Modifier.width(8.dp)) + if (glassesConnected) { + Box( + modifier = + Modifier + .size(44.dp) + .clip(CircleShape) + .background(SurfaceDark) + .clickable { showGlassesPanel = true }, + contentAlignment = Alignment.Center, + ) { + Icon(Eyeglasses2Icon, contentDescription = "Glasses", tint = Accent, modifier = Modifier.size(24.dp)) + } + Spacer(Modifier.width(8.dp)) + } + Box( modifier = Modifier @@ -2472,6 +2609,8 @@ class UnifiedActivity : } } + if (showGlassesPanel) GlassesSettingsSheet(onDismiss = { showGlassesPanel = false }) + AnimatedVisibility( visible = isSearchExpanded && !isDownloadsTab, enter = diff --git a/app/src/main/cpp/winlator/vk/vk_dispatch.c b/app/src/main/cpp/winlator/vk/vk_dispatch.c index a698c30d9..870c1456c 100644 --- a/app/src/main/cpp/winlator/vk/vk_dispatch.c +++ b/app/src/main/cpp/winlator/vk/vk_dispatch.c @@ -150,6 +150,7 @@ bool vkd_load_instance(VkInstance instance) { LOAD(CmdDraw); LOAD(CmdPipelineBarrier); LOAD(CmdCopyBufferToImage); + LOAD(CmdBlitImage); // Queue LOAD(QueueSubmit); diff --git a/app/src/main/cpp/winlator/vk/vk_dispatch.h b/app/src/main/cpp/winlator/vk/vk_dispatch.h index bf37d977d..812b084b6 100644 --- a/app/src/main/cpp/winlator/vk/vk_dispatch.h +++ b/app/src/main/cpp/winlator/vk/vk_dispatch.h @@ -127,6 +127,7 @@ typedef struct VkDispatch { PFN_vkCmdDraw CmdDraw; PFN_vkCmdPipelineBarrier CmdPipelineBarrier; PFN_vkCmdCopyBufferToImage CmdCopyBufferToImage; + PFN_vkCmdBlitImage CmdBlitImage; // Queue PFN_vkQueueSubmit QueueSubmit; @@ -253,6 +254,7 @@ void vkd_unload(void); #define vkCmdDraw vkd.CmdDraw #define vkCmdPipelineBarrier vkd.CmdPipelineBarrier #define vkCmdCopyBufferToImage vkd.CmdCopyBufferToImage +#define vkCmdBlitImage vkd.CmdBlitImage #define vkQueueSubmit vkd.QueueSubmit #define vkQueueWaitIdle vkd.QueueWaitIdle diff --git a/app/src/main/cpp/winlator/vk/vk_renderer.c b/app/src/main/cpp/winlator/vk/vk_renderer.c index b65be3eb7..4e59b1ce3 100644 --- a/app/src/main/cpp/winlator/vk/vk_renderer.c +++ b/app/src/main/cpp/winlator/vk/vk_renderer.c @@ -23,6 +23,7 @@ #include #include #include +#include // SPIR-V shader byte arrays generated at build time by glslc + bin2c.cmake. #include "shaders/window_vert.spv.h" @@ -1200,6 +1201,10 @@ static bool create_swapchain(VkRenderer* r, uint32_t fallback_width, uint32_t fa sci.imageExtent = extent; sci.imageArrayLayers = 1; sci.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; + if (r->record_blit_src + && (caps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT)) { + sci.imageUsage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT; // blit source for the encoder mirror + } sci.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; sci.preTransform = pre_transform; sci.compositeAlpha = (caps.supportedCompositeAlpha & VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR) @@ -1304,6 +1309,257 @@ static void destroy_swapchain(VkRenderer* r) { if (r->swapchain) { vkDestroySwapchainKHR(r->device, r->swapchain, NULL); r->swapchain = VK_NULL_HANDLE; } } +// ============================================================ +// Recording mirror swapchain (screen-capture target) +// ============================================================ + +static void destroy_record_ui_resources(VkRenderer* r) { + VkRecordSwap* rec = &r->rec; + for (uint32_t i = 0; i < VK_MAX_RECORD_IMAGES; i++) { + if (rec->framebuffers[i]) { vkDestroyFramebuffer(r->device, rec->framebuffers[i], NULL); rec->framebuffers[i] = VK_NULL_HANDLE; } + if (rec->views[i]) { vkDestroyImageView(r->device, rec->views[i], NULL); rec->views[i] = VK_NULL_HANDLE; } + } + if (rec->ui_pipeline) { vkDestroyPipeline(r->device, rec->ui_pipeline, NULL); rec->ui_pipeline = VK_NULL_HANDLE; } + if (rec->ui_pass) { vkDestroyRenderPass(r->device, rec->ui_pass, NULL); rec->ui_pass = VK_NULL_HANDLE; } + if (rec->ui_texture) { vkr_texture_destroy(r, rec->ui_texture); rec->ui_texture = NULL; } + rec->fb_built = false; +} + +// Build the LOAD render pass, framebuffers, and blended blit pipeline for the Record UI composite. +static bool build_record_ui_resources(VkRenderer* r) { + VkRecordSwap* rec = &r->rec; + if (rec->image_count == 0) return false; + if (!r->pipelines_built) return false; + + VkAttachmentDescription att = {0}; + att.format = rec->format; + att.samples = VK_SAMPLE_COUNT_1_BIT; + att.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; + att.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + att.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + att.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + att.initialLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + att.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + VkAttachmentReference ref = {0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkSubpassDescription sp = {0}; + sp.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + sp.colorAttachmentCount = 1; + sp.pColorAttachments = &ref; + VkSubpassDependency dep = {0}; + dep.srcSubpass = VK_SUBPASS_EXTERNAL; + dep.dstSubpass = 0; + dep.srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT; + dep.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + dep.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + dep.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + VkRenderPassCreateInfo rci = {VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO}; + rci.attachmentCount = 1; + rci.pAttachments = &att; + rci.subpassCount = 1; + rci.pSubpasses = &sp; + rci.dependencyCount = 1; + rci.pDependencies = &dep; + if (vkCreateRenderPass(r->device, &rci, NULL, &rec->ui_pass) != VK_SUCCESS) { + VK_LOGE("record: ui_pass create failed"); + return false; + } + + for (uint32_t i = 0; i < rec->image_count; i++) { + VkImageViewCreateInfo ivci = {VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO}; + ivci.image = rec->images[i]; + ivci.viewType = VK_IMAGE_VIEW_TYPE_2D; + ivci.format = rec->format; + ivci.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + ivci.subresourceRange.levelCount = 1; + ivci.subresourceRange.layerCount = 1; + if (vkCreateImageView(r->device, &ivci, NULL, &rec->views[i]) != VK_SUCCESS) goto fail; + + VkFramebufferCreateInfo fbci = {VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO}; + fbci.renderPass = rec->ui_pass; + fbci.attachmentCount = 1; + fbci.pAttachments = &rec->views[i]; + fbci.width = rec->extent.width; + fbci.height = rec->extent.height; + fbci.layers = 1; + if (vkCreateFramebuffer(r->device, &fbci, NULL, &rec->framebuffers[i]) != VK_SUCCESS) goto fail; + } + + VkShaderModule vs = load_shader_module(r, quad_vert, quad_vert_size); + VkShaderModule fs = load_shader_module(r, blit_frag, blit_frag_size); + if (vs && fs) { + rec->ui_pipeline = create_graphics_pipeline( + r, vs, fs, r->pipelines.effect_layout, rec->ui_pass, false, true, NULL); + } + if (vs) vkDestroyShaderModule(r->device, vs, NULL); + if (fs) vkDestroyShaderModule(r->device, fs, NULL); + if (!rec->ui_pipeline) { VK_LOGE("record: ui_pipeline create failed"); goto fail; } + + rec->fb_built = true; + VK_LOGI("record: UI composite resources ready"); + return true; + +fail: + destroy_record_ui_resources(r); + return false; +} + +static void destroy_record_swapchain(VkRenderer* r) { + destroy_record_ui_resources(r); + VkRecordSwap* rec = &r->rec; + for (uint32_t i = 0; i < rec->image_count; i++) { + if (rec->present_ready[i]) { + vkDestroySemaphore(r->device, rec->present_ready[i], NULL); + rec->present_ready[i] = VK_NULL_HANDLE; + } + } + for (uint32_t i = 0; i < VK_FRAMES_IN_FLIGHT; i++) { + if (rec->acquire[i]) { + vkDestroySemaphore(r->device, rec->acquire[i], NULL); + rec->acquire[i] = VK_NULL_HANDLE; + } + } + rec->image_count = 0; + if (rec->swapchain) { vkDestroySwapchainKHR(r->device, rec->swapchain, NULL); rec->swapchain = VK_NULL_HANDLE; } + if (rec->surface) { vkDestroySurfaceKHR(r->instance, rec->surface, NULL); rec->surface = VK_NULL_HANDLE; } + if (rec->anw) { ANativeWindow_release(rec->anw); rec->anw = NULL; } + rec->active = false; + rec->disabled = false; + rec->ui_enabled = false; +} + +static bool create_record_swapchain(VkRenderer* r) { + VkRecordSwap* rec = &r->rec; + if (!rec->anw) return false; + + VkAndroidSurfaceCreateInfoKHR aci = {VK_STRUCTURE_TYPE_ANDROID_SURFACE_CREATE_INFO_KHR}; + aci.window = rec->anw; + if (vkCreateAndroidSurfaceKHR(r->instance, &aci, NULL, &rec->surface) != VK_SUCCESS) { + VK_LOGE("record: vkCreateAndroidSurfaceKHR failed"); + return false; + } + + VkBool32 supported = VK_FALSE; + vkGetPhysicalDeviceSurfaceSupportKHR(r->physical_device, r->graphics_queue_family, + rec->surface, &supported); + if (!supported) { VK_LOGE("record: surface not presentable"); goto fail; } + + VkSurfaceCapabilitiesKHR caps; + if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(r->physical_device, rec->surface, &caps) != VK_SUCCESS) { + VK_LOGE("record: surface caps query failed"); + goto fail; + } + if (!(caps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_DST_BIT)) { + VK_LOGE("record: encoder surface lacks TRANSFER_DST usage (0x%x); cannot capture", + caps.supportedUsageFlags); + goto fail; + } + + uint32_t fmt_count = 0; + if (vkGetPhysicalDeviceSurfaceFormatsKHR(r->physical_device, rec->surface, &fmt_count, NULL) != VK_SUCCESS + || fmt_count == 0) { + VK_LOGE("record: surface formats query failed"); + goto fail; + } + VkSurfaceFormatKHR* fmts = calloc(fmt_count, sizeof(VkSurfaceFormatKHR)); + if (!fmts) goto fail; + vkGetPhysicalDeviceSurfaceFormatsKHR(r->physical_device, rec->surface, &fmt_count, fmts); + VkSurfaceFormatKHR chosen = fmts[0]; + for (uint32_t i = 0; i < fmt_count; i++) { + if (fmts[i].format == VK_FORMAT_R8G8B8A8_UNORM) { chosen = fmts[i]; break; } + if (fmts[i].format == VK_FORMAT_B8G8R8A8_UNORM) chosen = fmts[i]; + } + free(fmts); + rec->format = chosen.format; + + VkPresentModeKHR present_mode = VK_PRESENT_MODE_FIFO_KHR; // prefer MAILBOX below + uint32_t pm_count = 0; + vkGetPhysicalDeviceSurfacePresentModesKHR(r->physical_device, rec->surface, &pm_count, NULL); + if (pm_count > 0) { + VkPresentModeKHR* pms = calloc(pm_count, sizeof(VkPresentModeKHR)); + if (pms) { + vkGetPhysicalDeviceSurfacePresentModesKHR(r->physical_device, rec->surface, &pm_count, pms); + for (uint32_t i = 0; i < pm_count; i++) { + if (pms[i] == VK_PRESENT_MODE_MAILBOX_KHR) { present_mode = VK_PRESENT_MODE_MAILBOX_KHR; break; } + } + free(pms); + } + } + + VkExtent2D extent = caps.currentExtent; + if (extent.width == 0xFFFFFFFFu) { + int w = ANativeWindow_getWidth(rec->anw); + int h = ANativeWindow_getHeight(rec->anw); + extent.width = w > 0 ? (uint32_t)w : r->swapchain_extent.width; + extent.height = h > 0 ? (uint32_t)h : r->swapchain_extent.height; + } + if (extent.width == 0 || extent.height == 0) goto fail; + rec->extent = extent; + + VkSurfaceTransformFlagBitsKHR pre = + (caps.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR) + ? VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR : caps.currentTransform; + + uint32_t image_count = caps.minImageCount + 1; + if (caps.maxImageCount > 0 && image_count > caps.maxImageCount) image_count = caps.maxImageCount; + if (image_count > VK_MAX_RECORD_IMAGES) image_count = VK_MAX_RECORD_IMAGES; + + VkSwapchainCreateInfoKHR sci = {VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR}; + sci.surface = rec->surface; + sci.minImageCount = image_count; + sci.imageFormat = chosen.format; + sci.imageColorSpace = chosen.colorSpace; + sci.imageExtent = extent; + sci.imageArrayLayers = 1; + sci.imageUsage = VK_IMAGE_USAGE_TRANSFER_DST_BIT; + if (rec->ui_enabled && (caps.supportedUsageFlags & VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT)) { + sci.imageUsage |= VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; // Record UI renders onto the rec image + } else if (rec->ui_enabled) { + VK_LOGW("record: encoder surface can't be a color attachment; UI overlay disabled"); + rec->ui_enabled = false; + } + sci.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; + sci.preTransform = pre; + sci.compositeAlpha = (caps.supportedCompositeAlpha & VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR) + ? VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR : VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR; + sci.presentMode = present_mode; + sci.clipped = VK_TRUE; + if (vkCreateSwapchainKHR(r->device, &sci, NULL, &rec->swapchain) != VK_SUCCESS) { + VK_LOGE("record: vkCreateSwapchainKHR failed"); + goto fail; + } + + uint32_t got = 0; + if (vkGetSwapchainImagesKHR(r->device, rec->swapchain, &got, NULL) != VK_SUCCESS + || got == 0 || got > VK_MAX_RECORD_IMAGES) { + VK_LOGE("record: swapchain image count query failed (got=%u)", got); + goto fail; + } + if (vkGetSwapchainImagesKHR(r->device, rec->swapchain, &got, rec->images) != VK_SUCCESS) { + VK_LOGE("record: swapchain images query failed"); + goto fail; + } + rec->image_count = got; + + VkSemaphoreCreateInfo sem_ci = {VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; + for (uint32_t i = 0; i < got; i++) { + if (vkCreateSemaphore(r->device, &sem_ci, NULL, &rec->present_ready[i]) != VK_SUCCESS) goto fail; + } + for (uint32_t i = 0; i < VK_FRAMES_IN_FLIGHT; i++) { + if (vkCreateSemaphore(r->device, &sem_ci, NULL, &rec->acquire[i]) != VK_SUCCESS) goto fail; + } + VK_LOGI("record: mirror swapchain %ux%u images=%u", extent.width, extent.height, got); + + if (rec->ui_enabled && !build_record_ui_resources(r)) { + VK_LOGW("record: UI composite unavailable; capturing game only"); + rec->ui_enabled = false; + } + return true; + +fail: + destroy_record_swapchain(r); + return false; +} + // ============================================================ // Offscreen ping-pong (for effect chain) // ============================================================ @@ -1948,6 +2204,40 @@ static bool record_and_submit_frame(VkRenderer* r) { } VkSemaphore render_finished = r->swapchain_render_finished[image_index]; + // Sample the render rate down to the requested fps on a fixed grid, then acquire an encoder + // image to blit this frame into (bounded timeout so a busy encoder skips rather than stalls). + bool rec_this_frame = false; + uint32_t rec_index = 0; + bool rec_due = true; + if (r->rec.active && r->rec.min_interval_ns > 0) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + uint64_t now = (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec; + if (r->rec.last_capture_ns == 0) r->rec.last_capture_ns = now; + if (now < r->rec.last_capture_ns) { + rec_due = false; + } else { + r->rec.last_capture_ns += r->rec.min_interval_ns; + if (now > r->rec.last_capture_ns + 4ULL * r->rec.min_interval_ns) { + r->rec.last_capture_ns = now; // resync after a long stall + } + } + } + if (rec_due && r->rec.active && !r->rec.disabled && r->rec.swapchain) { + VkResult racq = vkAcquireNextImageKHR(r->device, r->rec.swapchain, 16000000ULL, + r->rec.acquire[r->frame_index], VK_NULL_HANDLE, &rec_index); + if (racq == VK_SUCCESS || racq == VK_SUBOPTIMAL_KHR) { + rec_this_frame = true; + if (r->rec.captured++ == 0) VK_LOGI("record: first frame captured (rec_index=%u)", rec_index); + } else if (racq == VK_ERROR_OUT_OF_DATE_KHR) { + r->rec.disabled = true; + VK_LOGW("record: mirror swapchain out of date; capture disabled"); + } else if ((r->rec.skipped++ % 120) == 0) { + VK_LOGW("record: no encoder image ready (code=%d, skipped=%llu)", + racq, (unsigned long long)r->rec.skipped); + } + } + vkResetFences(r->device, 1, &f->in_flight); VkCommandBufferBeginInfo bi = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; @@ -2029,17 +2319,88 @@ static bool record_and_submit_frame(VkRenderer* r) { vkCmdEndRenderPass(f->cmd); } + // Blit the final composited image (in PRESENT_SRC after the render pass) into the encoder image. + if (rec_this_frame) { + VkImage disp_img = r->swapchain_images[image_index]; + VkImage rec_img = r->rec.images[rec_index]; + vkr_image_barrier(f->cmd, disp_img, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT); + vkr_image_barrier(f->cmd, rec_img, + VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, VK_ACCESS_TRANSFER_WRITE_BIT); + + VkImageBlit blit = {0}; + blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + blit.srcSubresource.layerCount = 1; + blit.srcOffsets[1].x = (int32_t)r->swapchain_extent.width; + blit.srcOffsets[1].y = (int32_t)r->swapchain_extent.height; + blit.srcOffsets[1].z = 1; + blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + blit.dstSubresource.layerCount = 1; + blit.dstOffsets[1].x = (int32_t)r->rec.extent.width; + blit.dstOffsets[1].y = (int32_t)r->rec.extent.height; + blit.dstOffsets[1].z = 1; + vkCmdBlitImage(f->cmd, disp_img, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + rec_img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR); + + vkr_image_barrier(f->cmd, disp_img, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, + VK_ACCESS_TRANSFER_READ_BIT, 0); + + // Record UI: blend the overlay over the game, else just transition to presentable. + bool do_ui = r->rec.ui_enabled && r->rec.fb_built && r->rec.ui_pipeline + && r->rec.ui_texture && r->rec.ui_texture->ready + && rec_index < r->rec.image_count && r->rec.framebuffers[rec_index]; + if (do_ui) { + vkr_image_barrier(f->cmd, rec_img, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_ACCESS_TRANSFER_WRITE_BIT, + VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT); + VkRenderPassBeginInfo rp = {VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO}; + rp.renderPass = r->rec.ui_pass; + rp.framebuffer = r->rec.framebuffers[rec_index]; + rp.renderArea.extent = r->rec.extent; + vkCmdBeginRenderPass(f->cmd, &rp, VK_SUBPASS_CONTENTS_INLINE); + vkCmdBindPipeline(f->cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, r->rec.ui_pipeline); + VkViewport vp = {0, 0, (float)r->rec.extent.width, (float)r->rec.extent.height, 0.0f, 1.0f}; + VkRect2D scr = {{0, 0}, {r->rec.extent.width, r->rec.extent.height}}; + vkCmdSetViewport(f->cmd, 0, 1, &vp); + vkCmdSetScissor(f->cmd, 0, 1, &scr); + float pc[6] = {(float)r->rec.extent.width, (float)r->rec.extent.height, 0.0f, 0.0f, 0.0f, 0.0f}; + vkCmdPushConstants(f->cmd, r->pipelines.effect_layout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), pc); + vkCmdBindDescriptorSets(f->cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, r->pipelines.effect_layout, + 0, 1, &r->rec.ui_texture->descriptor_set, 0, NULL); + vkCmdDraw(f->cmd, 3, 1, 0, 0); + vkCmdEndRenderPass(f->cmd); // leaves rec image in PRESENT_SRC + } else { + vkr_image_barrier(f->cmd, rec_img, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, + VK_ACCESS_TRANSFER_WRITE_BIT, 0); + } + } + vkEndCommandBuffer(f->cmd); - VkPipelineStageFlags wait_stage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + // The mirror's acquire/present-ready semaphores are appended only when capturing this frame. + VkPipelineStageFlags wait_stages[2] = { + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT }; + VkSemaphore wait_sems[2] = { f->image_available, r->rec.acquire[r->frame_index] }; + VkSemaphore signal_sems[2] = { + render_finished, rec_this_frame ? r->rec.present_ready[rec_index] : VK_NULL_HANDLE }; VkSubmitInfo si = {VK_STRUCTURE_TYPE_SUBMIT_INFO}; - si.waitSemaphoreCount = 1; - si.pWaitSemaphores = &f->image_available; - si.pWaitDstStageMask = &wait_stage; + si.waitSemaphoreCount = rec_this_frame ? 2u : 1u; + si.pWaitSemaphores = wait_sems; + si.pWaitDstStageMask = wait_stages; si.commandBufferCount = 1; si.pCommandBuffers = &f->cmd; - si.signalSemaphoreCount = 1; - si.pSignalSemaphores = &render_finished; + si.signalSemaphoreCount = rec_this_frame ? 2u : 1u; + si.pSignalSemaphores = signal_sems; pthread_mutex_lock(&r->queue_mutex); VkResult sr = vkQueueSubmit(r->graphics_queue, 1, &si, f->in_flight); @@ -2068,6 +2429,20 @@ static bool record_and_submit_frame(VkRenderer* r) { pthread_mutex_lock(&r->queue_mutex); VkResult pr = vkQueuePresentKHR(r->graphics_queue, &pi); + // Present the mirror separately so its result doesn't disturb the display recreate logic below. + if (rec_this_frame) { + VkPresentInfoKHR rpi = {VK_STRUCTURE_TYPE_PRESENT_INFO_KHR}; + rpi.waitSemaphoreCount = 1; + rpi.pWaitSemaphores = &r->rec.present_ready[rec_index]; + rpi.swapchainCount = 1; + rpi.pSwapchains = &r->rec.swapchain; + rpi.pImageIndices = &rec_index; + VkResult rpr = vkQueuePresentKHR(r->graphics_queue, &rpi); + if (rpr != VK_SUCCESS && rpr != VK_SUBOPTIMAL_KHR) { + r->rec.disabled = true; + VK_LOGW("record: mirror present failed (%d); capture disabled", rpr); + } + } pthread_mutex_unlock(&r->queue_mutex); bool present_suboptimal = (pr == VK_SUBOPTIMAL_KHR) && !r->ignore_suboptimal; @@ -2212,6 +2587,7 @@ JNIEXPORT void JNICALL JNI_FN(nativeDestroy)(JNIEnv* env, jclass clazz, jlong ha free(r->batch_entry_scratch); free(r->batch_prepared_scratch); + destroy_record_swapchain(r); destroy_sgsr1_resources(r); destroy_offscreen(r); destroy_swapchain(r); @@ -2329,6 +2705,150 @@ JNIEXPORT void JNICALL JNI_FN(nativeSurfaceChanged)(JNIEnv* env, jclass clazz, j pthread_mutex_unlock(&r->render_mutex); } +JNIEXPORT jboolean JNICALL JNI_FN(nativeStartRecording)(JNIEnv* env, jclass clazz, jlong handle, jobject surface, jint fps, jboolean recordUI) { + (void)clazz; + VkRenderer* r = (VkRenderer*)(intptr_t)handle; + if (!r || !surface) return JNI_FALSE; + + lifecycle_begin(r); + + jboolean ok = JNI_FALSE; + do { + if (r->rec.active) { ok = JNI_TRUE; break; } + + ANativeWindow* anw = ANativeWindow_fromSurface(env, surface); + if (!anw) { VK_LOGE("record: ANativeWindow_fromSurface failed"); break; } + r->rec.anw = anw; + r->rec.ui_enabled = (recordUI == JNI_TRUE); + + if (r->device) vkDeviceWaitIdle(r->device); + + // Recreate the display swapchain so its images carry TRANSFER_SRC (the blit source). + r->record_blit_src = true; + if (r->surface) { + destroy_sgsr1_resources(r); + destroy_offscreen(r); + destroy_swapchain(r); + if (!create_swapchain(r, r->surface_extent.width, r->surface_extent.height)) { + VK_LOGE("record: display swapchain recreate failed"); + r->record_blit_src = false; + ANativeWindow_release(r->rec.anw); r->rec.anw = NULL; + break; + } + } + + if (!create_record_swapchain(r)) { + VK_LOGE("record: create_record_swapchain failed; recording will not capture"); + r->record_blit_src = false; // revert the display swapchain to its plain usage + if (r->surface) { + destroy_sgsr1_resources(r); + destroy_offscreen(r); + destroy_swapchain(r); + create_swapchain(r, r->surface_extent.width, r->surface_extent.height); + } + break; + } + + r->rec.active = true; + r->rec.disabled = false; + r->rec.captured = 0; + r->rec.skipped = 0; + r->rec.min_interval_ns = (fps > 0) ? (uint64_t)(1000000000.0 / (double)fps) : 0; + r->rec.last_capture_ns = 0; + ok = JNI_TRUE; + VK_LOGI("record: started (display %ux%u -> mirror %ux%u @%dfps)", + r->swapchain_extent.width, r->swapchain_extent.height, + r->rec.extent.width, r->rec.extent.height, (int)fps); + } while (0); + + pthread_mutex_lock(&r->scene_mutex); + r->surface_ready = (r->swapchain != VK_NULL_HANDLE); + pthread_mutex_unlock(&r->scene_mutex); + pthread_mutex_unlock(&r->render_mutex); + return ok; +} + +JNIEXPORT void JNICALL JNI_FN(nativeStopRecording)(JNIEnv* env, jclass clazz, jlong handle) { + (void)env; (void)clazz; + VkRenderer* r = (VkRenderer*)(intptr_t)handle; + if (!r) return; + + lifecycle_begin(r); + if (r->device) vkDeviceWaitIdle(r->device); + + destroy_record_swapchain(r); + + if (r->record_blit_src) { + r->record_blit_src = false; + if (r->surface) { + destroy_sgsr1_resources(r); + destroy_offscreen(r); + destroy_swapchain(r); + if (!create_swapchain(r, r->surface_extent.width, r->surface_extent.height)) { + VK_LOGE("record: display swapchain restore failed after stop"); + } + } + } + + pthread_mutex_lock(&r->scene_mutex); + r->surface_ready = (r->swapchain != VK_NULL_HANDLE); + pthread_mutex_unlock(&r->scene_mutex); + pthread_mutex_unlock(&r->render_mutex); +} + +// Upload the latest overlay snapshot (direct ByteBuffer of BGRA pixels) for the Record UI composite. +JNIEXPORT void JNICALL JNI_FN(nativeUpdateRecordUITexture)(JNIEnv* env, jclass clazz, jlong handle, + jobject buffer, jint w, jint h) { + (void)clazz; + VkRenderer* r = (VkRenderer*)(intptr_t)handle; + if (!r || !buffer || w <= 0 || h <= 0) return; + void* data = (*env)->GetDirectBufferAddress(env, buffer); + jlong cap = (*env)->GetDirectBufferCapacity(env, buffer); + if (!data || cap < (jlong)w * (jlong)h * 4) return; + + pthread_mutex_lock(&r->render_mutex); + if (r->rec.active && r->rec.ui_enabled) { + size_t bytes = (size_t)w * (size_t)h * 4u; + if (r->rec.ui_texture == NULL + || r->rec.ui_texture->width != (uint32_t)w + || r->rec.ui_texture->height != (uint32_t)h) { + if (r->rec.ui_texture) { vkr_texture_destroy(r, r->rec.ui_texture); r->rec.ui_texture = NULL; } + r->rec.ui_texture = vkr_texture_create_uploaded(r, (uint32_t)w, (uint32_t)h, data, bytes, (uint32_t)w); + } else { + vkr_texture_update(r, r->rec.ui_texture, (uint32_t)w, (uint32_t)h, data, bytes, + (uint32_t)w, 0, 0, (uint32_t)w, (uint32_t)h); + } + } + pthread_mutex_unlock(&r->render_mutex); +} + +// Dimensions of the composited image (swapchain extent), which differ from the SurfaceView size +// under display rotation; the encoder is sized to these so the capture isn't squished. +JNIEXPORT jint JNICALL JNI_FN(nativeGetRecordWidth)(JNIEnv* env, jclass clazz, jlong handle) { + (void)env; (void)clazz; + VkRenderer* r = (VkRenderer*)(intptr_t)handle; + return (r != NULL) ? (jint)r->swapchain_extent.width : 0; +} + +JNIEXPORT jint JNICALL JNI_FN(nativeGetRecordHeight)(JNIEnv* env, jclass clazz, jlong handle) { + (void)env; (void)clazz; + VkRenderer* r = (VkRenderer*)(intptr_t)handle; + return (r != NULL) ? (jint)r->swapchain_extent.height : 0; +} + +// Clockwise degrees to rotate the recording for upright playback (undoes the display preTransform). +JNIEXPORT jint JNICALL JNI_FN(nativeGetRecordOrientationHint)(JNIEnv* env, jclass clazz, jlong handle) { + (void)env; (void)clazz; + VkRenderer* r = (VkRenderer*)(intptr_t)handle; + if (r == NULL) return 0; + switch (r->swapchain_transform) { + case VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR: return 270; + case VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR: return 180; + case VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR: return 90; + default: return 0; + } +} + JNIEXPORT void JNICALL JNI_FN(nativeSurfaceDestroyed)(JNIEnv* env, jclass clazz, jlong handle) { (void)env; (void)clazz; VkRenderer* r = (VkRenderer*)(intptr_t)handle; diff --git a/app/src/main/cpp/winlator/vk/vk_state.h b/app/src/main/cpp/winlator/vk/vk_state.h index 74c0a414f..0eae82af5 100644 --- a/app/src/main/cpp/winlator/vk/vk_state.h +++ b/app/src/main/cpp/winlator/vk/vk_state.h @@ -22,6 +22,8 @@ #define VK_FRAMES_IN_FLIGHT 2 #define VK_MAX_SWAPCHAIN_IMAGES 8 +// Encoder input-surface swapchains can expose many more images than a display swapchain. +#define VK_MAX_RECORD_IMAGES 32 #define VK_MAX_EFFECTS 8 #define VK_MAX_RENDERABLE_WINDOWS 64 // Number of in-flight upload slots. Each slot owns a persistently-mapped staging buffer, @@ -219,6 +221,35 @@ typedef struct VkSgsr1State { uint32_t height; } VkSgsr1State; +// Recording mirror: a second swapchain on a MediaCodec input surface; each frame is blitted from +// the display swapchain into it and co-presented. Gated on rec.active. +typedef struct VkRecordSwap { + bool active; + bool disabled; + ANativeWindow* anw; + VkSurfaceKHR surface; + VkSwapchainKHR swapchain; + VkFormat format; + VkExtent2D extent; + uint32_t image_count; + VkImage images[VK_MAX_RECORD_IMAGES]; + VkSemaphore acquire[VK_FRAMES_IN_FLIGHT]; + VkSemaphore present_ready[VK_MAX_RECORD_IMAGES]; + uint64_t captured; + uint64_t skipped; + uint64_t min_interval_ns; + uint64_t last_capture_ns; + + // Record UI: alpha-blend an overlay texture over each captured frame. + bool ui_enabled; + VkRenderPass ui_pass; + VkPipeline ui_pipeline; + VkImageView views[VK_MAX_RECORD_IMAGES]; + VkFramebuffer framebuffers[VK_MAX_RECORD_IMAGES]; + bool fb_built; + struct VkTexture* ui_texture; +} VkRecordSwap; + // ============================================================ // Staging pool for async texture uploads // ============================================================ @@ -367,6 +398,10 @@ typedef struct VkRenderer { bool offscreen_built; VkSgsr1State sgsr1; + // record_blit_src adds TRANSFER_SRC usage to the display swapchain (toggled by start/stop recording). + bool record_blit_src; + VkRecordSwap rec; + // Quad vertex buffer (window/cursor) VkBuffer quad_vbo; VkDeviceMemory quad_vbo_memory; diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 61f29a91a..986e4d523 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1334,6 +1334,47 @@ Installeret sti: Indlæser Workshop-elementer Søg i Workshop-elementer Mislykket + + Udgang + Ekstern skærm registreret + Send spillet til den tilsluttede skærm? Dine skærmknapper og menuen forbliver på denne telefon. + Skift skærme + Spejl + Spil flyttet til ekstern skærm. Knapperne forbliver på din telefon. + Indstillinger for trådløs skærm er ikke tilgængelige på denne enhed. + Ekstern skærm afbrudt — spillet er vendt tilbage til telefonen. + Skærmudgang + Spillet vises på den eksterne skærm. + Tilsluttet skærm + Opløsning + Opdateringshastighed + Billedformat + Tilpas (format) + Stræk (udfyld) + Zoom (beskær) + Tilbage til telefonen + %1$d Hz + Cast til et tv + Tilslut en USB-C-/HDMI-adapter, eller tryk nedenfor for at forbinde en trådløs skærm (Miracast / Wi-Fi Direct). Spillet vises på tv\'et, mens disse knapper forbliver på din telefon. + Forbind trådløs skærm + Tv\'er med kun Chromecast kan ikke vise live-spil med private knapper — brug en trådløs skærm eller en USB‑C-/HDMI-adapter for det bedste resultat. + De tilstande, din skærm rapporterer, ændrer dens faktiske udgang (den kan blive sort et øjeblik, mens den geninitialiserer). Andre opløsninger gengives i den størrelse og skaleres via hardware til panelet — over den oprindelige bliver det skarpere (supersampling), under bliver det hurtigere. + Denne telefon sender skærmen ud i dens oprindelige %1$s og skifter ikke tilstand, så dette indstiller renderingsopløsningen — den hardware‑skaleres til panelet (højere end oprindelig giver skarpere billede, lavere kører hurtigere). + Spiltilstand (lav latenstid) + Til + Fra + Anmoder om tv\'ets spil-/lavlatensstilstand (HDMI ALLM) for at minimere inputforsinkelse. Vises kun, når den tilsluttede skærm understøtter det. + Send spil til skærm + En skærm er tilsluttet. Send spillet til den — dine knapper og menuen forbliver på denne telefon. Du kan altid hente det tilbage hertil. + Skærm + Briller + Lysstyrke + Skygge (dæmpning) + 3D (side om side) + Opdateringshastighed + Solfilter + Lydstyrke + Sendes direkte til dine Viture-briller via USB. Ny diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 23c44fbde..fc3317f22 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1334,6 +1334,47 @@ Installierter Pfad: Workshop-Elemente werden geladen Workshop-Elemente suchen Fehlgeschlagen + + Ausgabe + Externes Display erkannt + Das Spiel an das verbundene Display senden? Deine Bildschirmsteuerung und das Menü bleiben auf diesem Telefon. + Displays wechseln + Spiegeln + Spiel auf externes Display verschoben. Die Steuerung bleibt auf deinem Telefon. + Einstellungen für drahtlose Displays sind auf diesem Gerät nicht verfügbar. + Externes Display getrennt — Spiel zum Telefon zurückgekehrt. + Display-Ausgabe + Das Spiel wird auf dem externen Display angezeigt. + Verbundenes Display + Auflösung + Bildwiederholrate + Seitenverhältnis + Anpassen (Seitenverhältnis) + Strecken (Füllen) + Zoom (Zuschneiden) + Zurück zum Telefon + %1$d Hz + An einen Fernseher casten + Schließe einen USB-C-/HDMI-Adapter an oder tippe unten, um ein drahtloses Display (Miracast / Wi-Fi Direct) zu verbinden. Das Spiel wird auf dem Fernseher angezeigt, während diese Steuerung auf deinem Telefon bleibt. + Drahtloses Display verbinden + Fernseher nur mit Chromecast können kein Live-Gameplay mit privater Steuerung anzeigen — verwende für das beste Ergebnis ein drahtloses Display oder einen USB‑C-/HDMI-Adapter. + Die von deinem Display gemeldeten Modi ändern die tatsächliche Ausgabe (es kann kurz schwarz werden, während es neu initialisiert). Andere Auflösungen werden in dieser Größe gerendert und per Hardware auf das Panel skaliert — über der nativen schärfer (Supersampling), darunter schneller. + Dieses Telefon gibt das Display in seiner nativen Auflösung %1$s aus und wechselt den Modus nicht; dies legt daher die Renderauflösung fest — sie wird hardwareseitig auf das Panel skaliert (höher als nativ schärft, niedriger läuft schneller). + Spielmodus (geringe Latenz) + Ein + Aus + Fordert den Spiel-/Niedriglatenzmodus des Fernsehers (HDMI ALLM) an, um die Eingabeverzögerung zu minimieren. Wird nur angezeigt, wenn das verbundene Display dies unterstützt. + Spiel an Display senden + Ein Display ist verbunden. Sende das Spiel dorthin — deine Steuerung und das Menü bleiben auf diesem Telefon. Du kannst es jederzeit hierher zurückholen. + Anzeige + Brille + Helligkeit + Tönung (Abdunkeln) + 3D (Side-by-Side) + Bildwiederholrate + Sonnenschutz + Lautstärke + Direkt per USB an deine Viture-Brille gesendet. Neu diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 010aa4e28..47b9a0279 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1334,6 +1334,47 @@ Ruta instalada: Cargando elementos de Workshop Buscar elementos de Workshop Fallido + + Salida + Pantalla externa detectada + ¿Enviar el juego a la pantalla conectada? Tus controles en pantalla y el menú permanecen en este teléfono. + Cambiar pantallas + Duplicar + Juego movido a la pantalla externa. Los controles permanecen en tu teléfono. + Los ajustes de pantalla inalámbrica no están disponibles en este dispositivo. + Pantalla externa desconectada: el juego volvió al teléfono. + Salida de pantalla + El juego se muestra en la pantalla externa. + Pantalla conectada + Resolución + Frecuencia de actualización + Relación de aspecto + Ajustar (aspecto) + Estirar (llenar) + Zoom (recortar) + Volver al teléfono + %1$d Hz + Enviar a una TV + Conecta un adaptador USB-C / HDMI, o toca abajo para conectar una pantalla inalámbrica (Miracast / Wi-Fi Direct). El juego se muestra en la TV mientras estos controles permanecen en tu teléfono. + Conectar pantalla inalámbrica + Las TV solo con Chromecast no pueden mostrar el juego en vivo con controles privados: usa una pantalla inalámbrica o un adaptador USB‑C/HDMI para el mejor resultado. + Los modos que informa tu pantalla cambian su salida real (puede quedar en negro un momento mientras se reinicia). Otras resoluciones se renderizan a ese tamaño y se escalan por hardware al panel: por encima de la nativa se ve más nítido (supermuestreo), por debajo va más rápido. + Este teléfono envía la pantalla a su resolución nativa %1$s y no cambia de modo, así que esto define la resolución de renderizado — se escala por hardware al panel (más que la nativa mejora la nitidez, menos va más rápido). + Modo de juego (baja latencia) + Activado + Desactivado + Solicita el modo de juego / baja latencia de la TV (HDMI ALLM) para minimizar el retardo de entrada. Se muestra solo cuando la pantalla conectada lo admite. + Enviar juego a la pantalla + Hay una pantalla conectada. Envía el juego a ella: tus controles y el menú permanecen en este teléfono. Puedes devolverlo aquí en cualquier momento. + Pantalla + Gafas + Brillo + Sombra (atenuación) + 3D (lado a lado) + Frecuencia de actualización + Filtro solar + Volumen + Se envía directamente a tus gafas Viture por USB. Nuevo diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index cb552cdfc..1437450d8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1334,6 +1334,47 @@ Chemin installé : Chargement des éléments du Workshop Rechercher des éléments du Workshop Échec + + Sortie + Écran externe détecté + Envoyer le jeu vers l\'écran connecté ? Vos commandes à l\'écran et le menu restent sur ce téléphone. + Changer d\'écran + Dupliquer + Jeu déplacé vers l\'écran externe. Les commandes restent sur votre téléphone. + Les paramètres d\'affichage sans fil ne sont pas disponibles sur cet appareil. + Écran externe déconnecté — le jeu est revenu sur le téléphone. + Sortie d\'affichage + Le jeu s\'affiche sur l\'écran externe. + Écran connecté + Résolution + Taux de rafraîchissement + Format d\'image + Ajuster (format) + Étirer (remplir) + Zoom (rogner) + Revenir au téléphone + %1$d Hz + Diffuser sur un téléviseur + Branchez un adaptateur USB-C / HDMI, ou touchez ci-dessous pour connecter un écran sans fil (Miracast / Wi-Fi Direct). Le jeu s\'affiche sur le téléviseur tandis que ces commandes restent sur votre téléphone. + Connecter un écran sans fil + Les téléviseurs uniquement Chromecast ne peuvent pas afficher le jeu en direct avec des commandes privées — utilisez un écran sans fil ou un adaptateur USB‑C/HDMI pour un meilleur résultat. + Les modes signalés par votre écran changent sa sortie réelle (il peut devenir noir un instant pendant la réinitialisation). Les autres résolutions sont rendues à cette taille et mises à l\'échelle matériellement vers la dalle — au-dessus de la native, c\'est plus net (suréchantillonnage), en dessous, c\'est plus rapide. + Ce téléphone affiche l\'écran dans sa résolution native %1$s et ne change pas de mode ; ceci définit donc la résolution de rendu — elle est mise à l\'échelle matérielle vers la dalle (au‑dessus du natif c\'est plus net, en dessous c\'est plus rapide). + Mode jeu (faible latence) + Activé + Désactivé + Demande le mode jeu / faible latence du téléviseur (HDMI ALLM) pour réduire la latence d\'entrée. Affiché uniquement lorsque l\'écran connecté le prend en charge. + Envoyer le jeu vers l\'écran + Un écran est connecté. Envoyez-y le jeu — vos commandes et le menu restent sur ce téléphone. Vous pouvez le ramener ici à tout moment. + Affichage + Lunettes + Luminosité + Teinte (assombrissement) + 3D (côte à côte) + Fréquence d\'actualisation + Filtre solaire + Volume + Envoyé directement à vos lunettes Viture via USB. Nouveau diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 78aa00407..b09908040 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1271,6 +1271,48 @@ Workshop आइटम लोड हो रहे हैं Workshop आइटम खोजें विफल + + आउटपुट + बाहरी डिस्प्ले का पता चला + गेम को कनेक्ट किए गए डिस्प्ले पर भेजें? आपके ऑन-स्क्रीन नियंत्रण और मेन्यू इसी फ़ोन पर रहेंगे। + डिस्प्ले बदलें + मिरर करें + गेम बाहरी डिस्प्ले पर ले जाया गया। नियंत्रण आपके फ़ोन पर रहेंगे। + इस डिवाइस पर वायरलेस डिस्प्ले सेटिंग्स उपलब्ध नहीं हैं। + बाहरी डिस्प्ले डिस्कनेक्ट हो गया — गेम फ़ोन पर वापस आ गया। + डिस्प्ले आउटपुट + गेम बाहरी डिस्प्ले पर दिख रहा है। + कनेक्ट किया गया डिस्प्ले + रिज़ॉल्यूशन + रिफ्रेश दर + आस्पेक्ट अनुपात + फ़िट (आस्पेक्ट) + स्ट्रेच (भरें) + ज़ूम (क्रॉप) + फ़ोन पर वापस जाएँ + %1$d Hz + किसी TV पर कास्ट करें + USB-C / HDMI अडैप्टर कनेक्ट करें, या वायरलेस डिस्प्ले (Miracast / Wi-Fi Direct) कनेक्ट करने के लिए नीचे टैप करें। गेम TV पर दिखेगा जबकि ये नियंत्रण आपके फ़ोन पर रहेंगे। + वायरलेस डिस्प्ले कनेक्ट करें + केवल-Chromecast वाले TV निजी नियंत्रण के साथ लाइव गेमप्ले नहीं दिखा सकते — सर्वोत्तम परिणाम के लिए वायरलेस डिस्प्ले या USB‑C/HDMI अडैप्टर का उपयोग करें। + आपका डिस्प्ले जिन मोड की रिपोर्ट करता है वे उसके वास्तविक आउटपुट को बदलते हैं (पुनः आरंभ होते समय यह कुछ पल के लिए काला हो सकता है)। अन्य रिज़ॉल्यूशन उसी आकार पर रेंडर होते हैं और हार्डवेयर द्वारा पैनल पर स्केल किए जाते हैं — नेटिव से अधिक पर तीक्ष्ण (सुपरसैंपलिंग), कम पर तेज़। + यह फ़ोन डिस्प्ले को उसके मूल %1$s पर भेजता है और उसका मोड नहीं बदलता, इसलिए यह रेंडर रिज़ॉल्यूशन सेट करता है — इसे हार्डवेयर द्वारा पैनल के अनुसार स्केल किया जाता है (मूल से अधिक पर तीक्ष्णता बढ़ती है, कम पर तेज़ चलता है)। + गेम मोड (कम विलंबता) + चालू + बंद + इनपुट विलंब कम करने के लिए TV का गेम / कम-विलंबता मोड (HDMI ALLM) अनुरोध करता है। केवल तब दिखता है जब कनेक्ट किया गया डिस्प्ले इसका समर्थन करता है। + गेम को डिस्प्ले पर भेजें + एक डिस्प्ले कनेक्ट है। गेम को उस पर भेजें — आपके नियंत्रण और मेन्यू इसी फ़ोन पर रहेंगे। आप इसे कभी भी यहाँ वापस ला सकते हैं। + डिस्प्ले + ग्लासेस + चमक + शेड (मंद करना) + 3D (साइड-बाय-साइड) + रिफ़्रेश दर + सनब्लॉक + वॉल्यूम + USB के माध्यम से सीधे आपके Viture ग्लासेस पर भेजा गया। + नया नाम बदलें जेस्चर प्रोफ़ाइल निर्यात करें diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8883a0cab..3e97851c0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1334,6 +1334,47 @@ Percorso installato: Caricamento elementi Workshop Cerca elementi Workshop Non riuscito + + Uscita + Display esterno rilevato + Inviare il gioco al display collegato? I comandi a schermo e il menu restano su questo telefono. + Cambia display + Duplica + Gioco spostato sul display esterno. I comandi restano sul telefono. + Le impostazioni del display wireless non sono disponibili su questo dispositivo. + Display esterno disconnesso — gioco tornato sul telefono. + Uscita display + Il gioco viene mostrato sul display esterno. + Display collegato + Risoluzione + Frequenza di aggiornamento + Proporzioni + Adatta (proporzioni) + Allunga (riempi) + Zoom (ritaglia) + Torna al telefono + %1$d Hz + Trasmetti a una TV + Collega un adattatore USB-C / HDMI, oppure tocca qui sotto per connettere un display wireless (Miracast / Wi-Fi Direct). Il gioco viene mostrato sulla TV mentre questi comandi restano sul telefono. + Connetti display wireless + Le TV solo Chromecast non possono mostrare il gioco in tempo reale con comandi privati: per il risultato migliore usa un display wireless o un adattatore USB‑C/HDMI. + Le modalità segnalate dal display ne cambiano l\'uscita reale (può diventare nero per un istante mentre si reinizializza). Le altre risoluzioni vengono renderizzate a quella dimensione e scalate via hardware sul pannello — sopra la nativa è più nitido (supersampling), sotto è più veloce. + Questo telefono invia lo schermo alla sua risoluzione nativa %1$s e non cambia modalità, quindi questo imposta la risoluzione di rendering — viene scalata via hardware sul pannello (oltre la nativa aumenta la nitidezza, sotto va più veloce). + Modalità gioco (bassa latenza) + Attiva + Disattiva + Richiede la modalità gioco / bassa latenza della TV (HDMI ALLM) per ridurre il ritardo di input. Mostrata solo quando il display collegato la supporta. + Invia il gioco al display + Un display è collegato. Inviaci il gioco: i comandi e il menu restano su questo telefono. Puoi riportarlo qui in qualsiasi momento. + Schermo + Occhiali + Luminosità + Oscuramento + 3D (affiancato) + Frequenza di aggiornamento + Filtro solare + Volume + Inviato direttamente ai tuoi occhiali Viture via USB. Nuovo diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 3ec6a6bb7..75d13312b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1335,6 +1335,47 @@ Workshop 항목 로드 중 Workshop 항목 검색 실패 + + 출력 + 외부 디스플레이 감지됨 + 게임을 연결된 디스플레이로 보낼까요? 화면 컨트롤과 메뉴는 이 휴대폰에 그대로 유지됩니다. + 디스플레이 전환 + 미러링 + 게임이 외부 디스플레이로 이동했습니다. 컨트롤은 휴대폰에 그대로 유지됩니다. + 이 기기에서는 무선 디스플레이 설정을 사용할 수 없습니다. + 외부 디스플레이 연결이 끊겨 게임이 휴대폰으로 돌아왔습니다. + 디스플레이 출력 + 게임이 외부 디스플레이에 표시되고 있습니다. + 연결된 디스플레이 + 해상도 + 주사율 + 화면 비율 + 맞춤 (비율) + 늘이기 (채우기) + 확대 (자르기) + 휴대폰으로 돌아가기 + %1$d Hz + TV로 캐스트 + USB-C / HDMI 어댑터를 연결하거나 아래를 눌러 무선 디스플레이(Miracast / Wi-Fi Direct)를 연결하세요. 게임은 TV에 표시되고 이 컨트롤은 휴대폰에 그대로 유지됩니다. + 무선 디스플레이 연결 + Chromecast 전용 TV는 비공개 컨트롤로 실시간 게임플레이를 표시할 수 없습니다 — 최상의 결과를 위해 무선 디스플레이나 USB‑C/HDMI 어댑터를 사용하세요. + 디스플레이가 보고하는 모드는 실제 출력을 변경합니다(재초기화되는 동안 잠시 검게 표시될 수 있음). 다른 해상도는 해당 크기로 렌더링되어 하드웨어로 패널에 맞게 스케일됩니다 — 기본보다 높으면 더 선명해지고(슈퍼샘플링) 낮으면 더 빨라집니다. + 이 휴대폰은 디스플레이를 기본 %1$s로 출력하며 모드를 전환하지 않으므로, 이 설정은 렌더링 해상도를 정합니다 — 패널에 맞춰 하드웨어로 스케일링됩니다(기본보다 높으면 선명해지고, 낮으면 더 빠릅니다). + 게임 모드 (낮은 지연) + 켜기 + 끄기 + 입력 지연을 최소화하기 위해 TV의 게임/저지연 모드(HDMI ALLM)를 요청합니다. 연결된 디스플레이가 지원할 때만 표시됩니다. + 게임을 디스플레이로 보내기 + 디스플레이가 연결되어 있습니다. 게임을 그곳으로 보내세요 — 컨트롤과 메뉴는 이 휴대폰에 그대로 유지됩니다. 언제든지 다시 가져올 수 있습니다. + 디스플레이 + 글래스 + 밝기 + 셰이드(디밍) + 3D(좌우 분할) + 주사율 + 햇빛 차단 + 볼륨 + USB로 Viture 글래스에 직접 전송됩니다. 새로 만들기 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1d723e8b3..2ce4ad53b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1340,6 +1340,47 @@ Zainstalowana ścieżka: Ładowanie elementów Workshop Szukaj elementów Workshop Niepowodzenie + + Wyjście + Wykryto zewnętrzny wyświetlacz + Wysłać grę na podłączony wyświetlacz? Sterowanie ekranowe i menu pozostaną na tym telefonie. + Przełącz wyświetlacze + Powiel + Gra przeniesiona na zewnętrzny wyświetlacz. Sterowanie pozostaje na telefonie. + Ustawienia wyświetlacza bezprzewodowego są niedostępne na tym urządzeniu. + Zewnętrzny wyświetlacz odłączony — gra wróciła na telefon. + Wyjście obrazu + Gra jest wyświetlana na zewnętrznym wyświetlaczu. + Podłączony wyświetlacz + Rozdzielczość + Częstotliwość odświeżania + Proporcje obrazu + Dopasuj (proporcje) + Rozciągnij (wypełnij) + Powiększ (przytnij) + Wróć do telefonu + %1$d Hz + Prześlij do telewizora + Podłącz adapter USB-C / HDMI lub dotknij poniżej, aby połączyć wyświetlacz bezprzewodowy (Miracast / Wi-Fi Direct). Gra wyświetla się na telewizorze, a te elementy sterujące pozostają na telefonie. + Połącz wyświetlacz bezprzewodowy + Telewizory tylko z Chromecastem nie mogą wyświetlać rozgrywki na żywo z prywatnym sterowaniem — aby uzyskać najlepszy efekt, użyj wyświetlacza bezprzewodowego lub adaptera USB‑C/HDMI. + Tryby zgłaszane przez wyświetlacz zmieniają jego rzeczywiste wyjście (może na chwilę zgasnąć podczas ponownej inicjalizacji). Inne rozdzielczości są renderowane w tym rozmiarze i sprzętowo skalowane do panelu — powyżej natywnej jest ostrzej (supersampling), poniżej działa szybciej. + Ten telefon wyświetla obraz w natywnej rozdzielczości %1$s i nie zmienia trybu, więc to ustawia rozdzielczość renderowania — jest ona sprzętowo skalowana do panelu (powyżej natywnej wyostrza, poniżej działa szybciej). + Tryb gry (niskie opóźnienie) + Wł. + Wył. + Żąda trybu gry / niskiego opóźnienia telewizora (HDMI ALLM), aby zminimalizować opóźnienie wejścia. Wyświetlane tylko, gdy podłączony wyświetlacz to obsługuje. + Wyślij grę na wyświetlacz + Wyświetlacz jest podłączony. Wyślij na niego grę — sterowanie i menu pozostaną na tym telefonie. Możesz w każdej chwili przywrócić ją tutaj. + Ekran + Okulary + Jasność + Przyciemnienie + 3D (obok siebie) + Częstotliwość odświeżania + Filtr słoneczny + Głośność + Wysyłane bezpośrednio do okularów Viture przez USB. Nowy diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 21552c058..f53a28750 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1334,6 +1334,47 @@ Caminho instalado: Carregando itens do Workshop Buscar itens do Workshop Falhou + + Saída + Tela externa detectada + Enviar o jogo para a tela conectada? Seus controles na tela e o menu permanecem neste telefone. + Trocar telas + Espelhar + Jogo movido para a tela externa. Os controles permanecem no seu telefone. + As configurações de tela sem fio não estão disponíveis neste dispositivo. + Tela externa desconectada — o jogo voltou para o telefone. + Saída de tela + O jogo está sendo exibido na tela externa. + Tela conectada + Resolução + Taxa de atualização + Proporção + Ajustar (proporção) + Esticar (preencher) + Zoom (cortar) + Voltar ao telefone + %1$d Hz + Transmitir para uma TV + Conecte um adaptador USB-C / HDMI ou toque abaixo para conectar uma tela sem fio (Miracast / Wi-Fi Direct). O jogo aparece na TV enquanto estes controles permanecem no seu telefone. + Conectar tela sem fio + TVs apenas com Chromecast não conseguem exibir o jogo ao vivo com controles privados — use uma tela sem fio ou um adaptador USB‑C/HDMI para o melhor resultado. + Os modos que sua tela informa mudam a saída real dela (pode ficar preta por um instante enquanto reinicializa). Outras resoluções são renderizadas nesse tamanho e escalonadas por hardware para o painel — acima da nativa fica mais nítido (supersampling), abaixo fica mais rápido. + Este telefone exibe a tela na resolução nativa %1$s e não troca de modo, então isto define a resolução de renderização — ela é escalada por hardware para o painel (acima da nativa deixa mais nítido, abaixo roda mais rápido). + Modo jogo (baixa latência) + Ligado + Desligado + Solicita o modo jogo / baixa latência da TV (HDMI ALLM) para minimizar o atraso de entrada. Exibido apenas quando a tela conectada é compatível. + Enviar jogo para a tela + Uma tela está conectada. Envie o jogo para ela — seus controles e o menu permanecem neste telefone. Você pode trazê-lo de volta aqui a qualquer momento. + Tela + Óculos + Brilho + Sombra (escurecimento) + 3D (lado a lado) + Taxa de atualização + Bloqueio solar + Volume + Enviado diretamente para seus óculos Viture via USB. Novo diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index b578bdaeb..e339bb769 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1334,6 +1334,48 @@ Cale instalata: Se încarcă elementele Workshop Caută elemente Workshop Eșuat + + Ieșire + Ecran extern detectat + Trimiteți jocul către ecranul conectat? Comenzile de pe ecran și meniul rămân pe acest telefon. + Comută ecranele + Oglindește + Joc mutat pe ecranul extern. Comenzile rămân pe telefon. + Setările pentru ecran wireless nu sunt disponibile pe acest dispozitiv. + Ecran extern deconectat — jocul a revenit pe telefon. + Ieșire ecran + Jocul este afișat pe ecranul extern. + Ecran conectat + Rezoluție + Rată de reîmprospătare + Raport de aspect + Potrivire (aspect) + Întinde (umple) + Zoom (decupează) + Înapoi la telefon + %1$d Hz + Proiectează pe un televizor + Conectează un adaptor USB-C / HDMI sau atinge mai jos pentru a conecta un ecran wireless (Miracast / Wi-Fi Direct). Jocul apare pe televizor, iar aceste comenzi rămân pe telefon. + Conectează ecran wireless + Televizoarele doar cu Chromecast nu pot afișa jocul live cu comenzi private — folosește un ecran wireless sau un adaptor USB‑C/HDMI pentru cel mai bun rezultat. + Modurile raportate de ecran îi schimbă ieșirea reală (poate deveni negru o clipă în timp ce se reinițializează). Alte rezoluții sunt randate la acea dimensiune și scalate hardware pe panou — peste cea nativă e mai clar (supereșantionare), sub ea e mai rapid. + Acest telefon redă afișajul la rezoluția sa nativă %1$s și nu schimbă modul, așa că aceasta setează rezoluția de randare — este scalată hardware pe panou (peste cea nativă crește claritatea, sub ea rulează mai rapid). + Mod joc (latență redusă) + Pornit + Oprit + Solicită modul joc / latență redusă al televizorului (HDMI ALLM) pentru a minimiza întârzierea la intrare. Afișat doar când ecranul conectat îl acceptă. + Trimite jocul la ecran + Un ecran este conectat. Trimite-i jocul — comenzile și meniul rămân pe acest telefon. Îl poți aduce înapoi aici oricând. + Ecran + Ochelari + Luminozitate + Umbră (atenuare) + 3D (alăturat) + Rată de reîmprospătare + Filtru solar + Volum + Trimis direct la ochelarii Viture prin USB. + Nou Redenumește Exportă profilul de gesturi diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b586219bd..cc3e47d17 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1240,6 +1240,48 @@ Загрузка элементов Workshop Поиск элементов Workshop Ошибка + + Вывод + Обнаружен внешний дисплей + Отправить игру на подключённый дисплей? Экранные элементы управления и меню останутся на этом телефоне. + Переключить дисплеи + Дублировать + Игра перенесена на внешний дисплей. Управление остаётся на телефоне. + Настройки беспроводного дисплея недоступны на этом устройстве. + Внешний дисплей отключён — игра вернулась на телефон. + Вывод изображения + Игра отображается на внешнем дисплее. + Подключённый дисплей + Разрешение + Частота обновления + Соотношение сторон + Вписать (пропорции) + Растянуть (заполнить) + Увеличить (обрезать) + Вернуть на телефон + %1$d Hz + Транслировать на телевизор + Подключите адаптер USB-C / HDMI или нажмите ниже, чтобы подключить беспроводной дисплей (Miracast / Wi-Fi Direct). Игра отображается на телевизоре, а это управление остаётся на телефоне. + Подключить беспроводной дисплей + Телевизоры только с Chromecast не могут показывать игру в реальном времени с приватным управлением — для лучшего результата используйте беспроводной дисплей или адаптер USB‑C/HDMI. + Режимы, которые сообщает дисплей, меняют его реальный вывод (он может ненадолго погаснуть при повторной инициализации). Другие разрешения рендерятся в этом размере и аппаратно масштабируются на панель — выше нативного резче (суперсэмплинг), ниже быстрее. + Этот телефон выводит изображение в собственном разрешении %1$s и не меняет режим, поэтому здесь задаётся разрешение рендеринга — оно аппаратно масштабируется под панель (выше нативного — чётче, ниже — быстрее). + Игровой режим (низкая задержка) + Вкл. + Выкл. + Запрашивает игровой режим / низкую задержку телевизора (HDMI ALLM) для уменьшения задержки ввода. Показывается только если подключённый дисплей это поддерживает. + Отправить игру на дисплей + Дисплей подключён. Отправьте игру на него — управление и меню останутся на этом телефоне. Вы можете вернуть её сюда в любой момент. + Экран + Очки + Яркость + Затемнение + 3D (бок о бок) + Частота обновления + Светофильтр + Громкость + Отправляется напрямую на очки Viture по USB. + Создать Переименовать diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index ba0f27476..1a9cc9f76 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1340,6 +1340,47 @@ Завантаження елементів Workshop Пошук елементів Workshop Помилка + + Вивід + Виявлено зовнішній дисплей + Надіслати гру на підключений дисплей? Екранні елементи керування та меню залишаться на цьому телефоні. + Перемкнути дисплеї + Дублювати + Гру переміщено на зовнішній дисплей. Керування залишається на телефоні. + Налаштування бездротового дисплея недоступні на цьому пристрої. + Зовнішній дисплей відключено — гра повернулася на телефон. + Вивід зображення + Гра відображається на зовнішньому дисплеї. + Підключений дисплей + Роздільна здатність + Частота оновлення + Співвідношення сторін + Вписати (пропорції) + Розтягнути (заповнити) + Збільшити (обрізати) + Повернути на телефон + %1$d Hz + Транслювати на телевізор + Підключіть адаптер USB-C / HDMI або торкніться нижче, щоб підключити бездротовий дисплей (Miracast / Wi-Fi Direct). Гра відображається на телевізорі, а це керування залишається на телефоні. + Підключити бездротовий дисплей + Телевізори лише з Chromecast не можуть показувати гру в реальному часі з приватним керуванням — для найкращого результату використовуйте бездротовий дисплей або адаптер USB‑C/HDMI. + Режими, про які повідомляє дисплей, змінюють його реальний вивід (він може ненадовго згаснути під час повторної ініціалізації). Інші роздільні здатності рендеряться в цьому розмірі й апаратно масштабуються на панель — вище за нативну різкіше (суперсемплінг), нижче швидше. + Цей телефон виводить зображення у власній роздільності %1$s і не змінює режим, тож тут задається роздільність рендерингу — вона апаратно масштабується під панель (вище за нативну — різкіше, нижче — швидше). + Ігровий режим (низька затримка) + Увімк. + Вимк. + Запитує ігровий режим / низьку затримку телевізора (HDMI ALLM), щоб зменшити затримку вводу. Показується лише коли підключений дисплей це підтримує. + Надіслати гру на дисплей + Дисплей підключено. Надішліть гру на нього — керування та меню залишаться на цьому телефоні. Ви можете будь-коли повернути її сюди. + Екран + Окуляри + Яскравість + Затемнення + 3D (поруч) + Частота оновлення + Світлофільтр + Гучність + Надсилається напряму на окуляри Viture через USB. Створити diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 57a39120e..75e992cac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1334,6 +1334,47 @@ 正在加载 Workshop 项目 搜索 Workshop 项目 失败 + + 输出 + 检测到外部显示器 + 将游戏发送到已连接的显示器?你的屏幕控制和菜单仍保留在本手机上。 + 切换显示器 + 镜像 + 游戏已移至外部显示器。控制仍保留在你的手机上。 + 此设备上无法使用无线显示设置。 + 外部显示器已断开——游戏已返回手机。 + 显示输出 + 游戏正在外部显示器上显示。 + 已连接的显示器 + 分辨率 + 刷新率 + 宽高比 + 适应(按比例) + 拉伸(填满) + 缩放(裁剪) + 返回手机 + %1$d Hz + 投射到电视 + 连接 USB-C / HDMI 适配器,或点按下方连接无线显示器(Miracast / Wi-Fi Direct)。游戏将在电视上显示,而这些控制仍保留在你的手机上。 + 连接无线显示器 + 仅支持 Chromecast 的电视无法以私有控制显示实时游戏画面——为获得最佳效果,请使用无线显示器或 USB‑C/HDMI 适配器。 + 显示器报告的模式会改变其实际输出(重新初始化时可能短暂黑屏)。其他分辨率会以该尺寸渲染并由硬件缩放到面板——高于原生更清晰(超采样),低于原生更流畅。 + 此手机以原生 %1$s 输出显示,且不会切换模式,因此这里设置的是渲染分辨率 —— 会由硬件缩放到面板(高于原生更锐利,低于原生更流畅)。 + 游戏模式(低延迟) + + + 请求电视的游戏/低延迟模式(HDMI ALLM)以尽量减少输入延迟。仅在已连接的显示器支持时显示。 + 将游戏发送到显示器 + 已连接显示器。将游戏发送到该显示器——你的控制和菜单仍保留在本手机上。你可以随时将其取回这里。 + 显示 + 眼镜 + 亮度 + 遮光(变暗) + 3D(左右) + 刷新率 + 遮光 + 音量 + 通过 USB 直接发送到你的 Viture 眼镜。 新建 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index dc318de81..1df42dfd8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1334,6 +1334,48 @@ 正在載入 Workshop 項目 搜尋 Workshop 項目 失敗 + + 輸出 + 偵測到外部顯示器 + 將遊戲傳送到已連接的顯示器?你的螢幕控制與選單仍保留在本手機上。 + 切換顯示器 + 鏡像 + 遊戲已移至外部顯示器。控制仍保留在你的手機上。 + 此裝置上無法使用無線顯示設定。 + 外部顯示器已中斷連線——遊戲已返回手機。 + 顯示輸出 + 遊戲正在外部顯示器上顯示。 + 已連接的顯示器 + 解析度 + 更新率 + 長寬比 + 符合(比例) + 拉伸(填滿) + 縮放(裁切) + 返回手機 + %1$d Hz + 投放到電視 + 連接 USB-C / HDMI 轉接器,或點選下方連接無線顯示器(Miracast / Wi-Fi Direct)。遊戲將在電視上顯示,而這些控制仍保留在你的手機上。 + 連接無線顯示器 + 僅支援 Chromecast 的電視無法以私人控制顯示即時遊戲畫面——為獲得最佳效果,請使用無線顯示器或 USB‑C/HDMI 轉接器。 + 顯示器回報的模式會改變其實際輸出(重新初始化時可能短暫黑屏)。其他解析度會以該尺寸算繪並由硬體縮放到面板——高於原生更清晰(超取樣),低於原生更流暢。 + 此手機以原生 %1$s 輸出顯示,且不會切換模式,因此這裡設定的是算繪解析度 —— 會由硬體縮放至面板(高於原生更銳利,低於原生更流暢)。 + 遊戲模式(低延遲) + + + 請求電視的遊戲/低延遲模式(HDMI ALLM)以盡量減少輸入延遲。僅在已連接的顯示器支援時顯示。 + 將遊戲傳送到顯示器 + 已連接顯示器。將遊戲傳送到該顯示器——你的控制與選單仍保留在本手機上。你可以隨時將其取回這裡。 + 顯示 + 眼鏡 + 亮度 + 遮光(變暗) + 3D(左右) + 更新率 + 遮光 + 音量 + 透過 USB 直接傳送到你的 Viture 眼鏡。 + 新增 重新命名 匯出手勢設定檔 diff --git a/app/src/main/res/values/refs.xml b/app/src/main/res/values/refs.xml index 8adf78560..7939af52a 100644 --- a/app/src/main/res/values/refs.xml +++ b/app/src/main/res/values/refs.xml @@ -21,11 +21,13 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90fbbddb2..6fce68fba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -790,7 +790,48 @@ E.g. META for META key, \n HUD Gyro FX + Output More + + External display detected + Send the game to the connected display? Your on‑screen controls and menu stay on this phone. + Swap Displays + Mirror + Game moved to external display. Controls stay on your phone. + Wireless display settings are unavailable on this device. + External display disconnected — game returned to phone. + Display Output + Game is showing on the external display. + Connected display + Resolution + Refresh Rate + Aspect Ratio + Fit (Aspect) + Stretch (Fill) + Zoom (Crop) + Return to phone + %1$d Hz + Cast to a TV + Connect a USB-C / HDMI adapter, or tap below to connect a wireless display (Miracast / Wi-Fi Direct). The game shows on the TV while these controls stay on your phone. + Connect Wireless Display + Chromecast‑only TVs can\'t show live gameplay with private controls — use a wireless display or a USB‑C/HDMI adapter for the best result. + Modes your display reports switch its actual output (it may briefly go black while it re‑initializes). Other resolutions render at that size and are hardware‑scaled to the panel — higher than native sharpens (supersampling), lower runs faster. + This phone outputs the display at its native %1$s and won\'t switch its mode, so this sets the render resolution — it\'s hardware‑scaled to the panel (higher than native sharpens, lower runs faster). + Game Mode (Low Latency) + On + Off + Requests the TV\'s low‑latency / game mode (HDMI ALLM) to minimize input lag. Shown only when the connected display supports it. + Send game to display + A display is connected. Send the game to it — your controls and menu stay on this phone. You can return it here any time. + Display + Glasses + Brightness + Shade (dimming) + 3D (side‑by‑side) + Refresh Rate + Sunblock + Volume + Sent directly to your Viture glasses over USB. Keyboard Controls RelMouse @@ -799,7 +840,18 @@ E.g. META for META key, \n PiP Zoom Tasks + Record Logs + Recording started + Recording saved to WinNative/Recordings + Couldn\'t start recording + Record Settings + Frame Rate + Resolution + Quality + Record UI + Include HUD and on-screen controls + Record Now Clear Pause Resume diff --git a/app/src/main/runtime/audio/alsaserver/ALSAClient.java b/app/src/main/runtime/audio/alsaserver/ALSAClient.java index 708260d96..b3ff7c30f 100644 --- a/app/src/main/runtime/audio/alsaserver/ALSAClient.java +++ b/app/src/main/runtime/audio/alsaserver/ALSAClient.java @@ -4,6 +4,7 @@ import android.media.AudioFormat; import android.media.AudioManager; import android.media.AudioTrack; +import com.winlator.cmod.runtime.display.recording.GameRecorder; import com.winlator.cmod.runtime.wine.EnvVars; import com.winlator.cmod.sharedmemory.SysVSharedMemory; import java.nio.ByteBuffer; @@ -234,6 +235,16 @@ public synchronized void writeDataToStream(ByteBuffer data) { applyAudioProcessing(data); } + // Tee the post-processed PCM into an active screen recording (a duplicate, no-op when idle). + GameRecorder recorder = GameRecorder.active(); + if (recorder != null) { + ByteBuffer tee = data.duplicate(); + tee.order(data.order()); + tee.position(0); + tee.limit(data.limit()); + recorder.onPcm(tee, sampleRate, channelCount, getPCMEncoding(dataType)); + } + while (data.position() != data.limit()) { int bytesWritten; try { diff --git a/app/src/main/runtime/display/ExternalDisplayController.java b/app/src/main/runtime/display/ExternalDisplayController.java new file mode 100644 index 000000000..01428d6f5 --- /dev/null +++ b/app/src/main/runtime/display/ExternalDisplayController.java @@ -0,0 +1,891 @@ +package com.winlator.cmod.runtime.display; + +import android.app.Activity; +import android.app.Presentation; +import android.content.Context; +import android.graphics.Color; +import android.hardware.display.DeviceProductInfo; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.Display; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.FrameLayout; + +import com.winlator.cmod.runtime.display.renderer.ViewTransformation; +import com.winlator.cmod.runtime.display.renderer.VulkanRenderer; +import com.winlator.cmod.runtime.display.ui.XServerSurfaceView; + +import java.util.ArrayList; +import java.util.List; + +/** + * Swaps the game onto a connected external display (USB-C/HDMI/Miracast/XR glasses) by moving the + * {@link XServerSurfaceView} into a {@link Presentation}, while controls and the menu stay on the + * phone. Offers a hybrid resolution/refresh list — real {@link Display.Mode}s switch the panel via + * preferredDisplayModeId; other tiers render via SurfaceHolder.setFixedSize and are hardware-scaled. + */ +public final class ExternalDisplayController { + private static final String TAG = "ExternalDisplay"; + + // Standard render-resolution tiers (by height); width is derived from the panel aspect ratio. + private static final int[] STANDARD_TIER_HEIGHTS = {2160, 1440, 1080, 720, 480}; + // Standard refresh tiers offered alongside the panel's detected rates (best-effort if unmatched). + private static final float[] STANDARD_REFRESH_RATES = {165f, 144f, 120f, 90f, 60f, 50f, 30f}; + private static final float REFRESH_EPSILON = 0.5f; // tolerates 59.94 vs 60.0 + + public interface Callbacks { + void onExternalDisplayConnected(Display display); + + void onExternalDisplayDisconnected(); + + /** Refresh the drawer menu (Output tab visibility/controls). */ + void onSwapStateChanged(boolean swapActive); + } + + // A selectable resolution. physical=true switches the panel mode; otherwise it's a render tier. + private static final class ResEntry { + final int w; + final int h; + final boolean physical; + final String label; + + ResEntry(int w, int h, boolean physical, String label) { + this.w = w; + this.h = h; + this.physical = physical; + this.label = label; + } + } + + private final Activity activity; + private final FrameLayout phoneFrame; + private final XServerSurfaceView gameView; + private final Callbacks callbacks; + private final DisplayManager displayManager; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + private GamePresentation presentation; + private Display externalDisplay; + private boolean swapActive; + private boolean listenerRegistered; + private int promptedDisplayId = -1; // guards against re-prompting for the same connection + + // Output-tab selections (only meaningful while swapped). + private final List resolutions = new ArrayList<>(); + private int selectedResolutionIndex = 0; + private final List refreshOptions = new ArrayList<>(); + private int selectedRefreshIndex = 0; + private boolean renderActive = false; // a setFixedSize render buffer is currently in effect + private boolean panelScalerLocked = false; // sink ignored a real mode switch — render-scale instead + private int modeRequestGen = 0; // guards a verify against a newer selection superseding it + private boolean gameMode = true; // request HDMI ALLM when the sink supports it + private String lastModesSignature = ""; // detects EDID re-advertisement (e.g. glasses unlocking 120Hz) + + private int fillMode = ViewTransformation.FILL_MODE_FIT; + private int savedPresentMode = VulkanRenderer.PRESENT_MODE_FIFO; + private float savedPhoneRefreshRate = 0f; + + // Phone surface size captured before the swap, to restore it exactly on return. + private int savedPhoneViewWidth = 0; + private int savedPhoneViewHeight = 0; + + // Shared Viture glasses controller + settings (owned app-wide by GlassesManager so the library and + // the in-game swap never double-claim the USB); values persist across both. + private final VitureGlasses viture; + private final GlassesManager.Listener glassesListener; + + public ExternalDisplayController(Activity activity, FrameLayout phoneFrame, + XServerSurfaceView gameView, Callbacks callbacks) { + this.activity = activity; + this.phoneFrame = phoneFrame; + this.gameView = gameView; + this.callbacks = callbacks; + this.displayManager = (DisplayManager) activity.getSystemService(Context.DISPLAY_SERVICE); + GlassesManager.INSTANCE.init(activity); + this.viture = GlassesManager.INSTANCE.glasses(); + this.glassesListener = (connectionChanged) -> { + // Only re-apply the mode / re-render on (re)connect — not on every slider tick, which would + // storm the drawer recompose and the display-mode command while dragging. + if (connectionChanged) { + if (viture != null && viture.isConnected() && swapActive) applyOutputMode(); + callbacks.onSwapStateChanged(swapActive); + } + }; + GlassesManager.INSTANCE.addListener(glassesListener); + } + + // ── Lifecycle ────────────────────────────────────────────────────────── + + public void start() { + if (displayManager == null) return; + if (!listenerRegistered) { + displayManager.registerDisplayListener(displayListener, mainHandler); + listenerRegistered = true; + } + maybePromptForDisplay(); + } + + // Offer the swap prompt at most once per connection (and never while swapped). + private void maybePromptForDisplay() { + Display d = findExternalDisplay(); + if (d != null && !swapActive && d.getDisplayId() != promptedDisplayId) { + promptedDisplayId = d.getDisplayId(); + callbacks.onExternalDisplayConnected(d); + } + } + + public void stop() { + if (displayManager != null && listenerRegistered) { + displayManager.unregisterDisplayListener(displayListener); + listenerRegistered = false; + } + } + + public void release() { + stop(); + GlassesManager.INSTANCE.removeListener(glassesListener); + exitSwap(); + } + + private final DisplayManager.DisplayListener displayListener = new DisplayManager.DisplayListener() { + @Override + public void onDisplayAdded(int displayId) { + maybePromptForDisplay(); + } + + @Override + public void onDisplayRemoved(int displayId) { + if (displayId == promptedDisplayId) promptedDisplayId = -1; + if (swapActive && externalDisplay != null && externalDisplay.getDisplayId() == displayId) { + exitSwap(); + callbacks.onExternalDisplayDisconnected(); + } + } + + @Override + public void onDisplayChanged(int displayId) { + if (swapActive && externalDisplay != null && externalDisplay.getDisplayId() == displayId) { + Display fresh = displayManager != null ? displayManager.getDisplay(displayId) : null; + if (fresh != null) externalDisplay = fresh; + if (!renderActive) { + // Re-scan if the sink re-advertised modes (glasses unlocking 120Hz, 2D↔3D toggle). + if (!modesSignature(externalDisplay).equals(lastModesSignature)) { + rebuildModeTables(); + } + syncSelectionToActiveMode(); + } + callbacks.onSwapStateChanged(true); + } + } + }; + + private static String modesSignature(Display d) { + if (d == null) return ""; + StringBuilder sb = new StringBuilder(); + try { + for (Display.Mode m : d.getSupportedModes()) { + sb.append(m.getPhysicalWidth()).append('x').append(m.getPhysicalHeight()) + .append('@').append(Math.round(m.getRefreshRate())).append(';'); + } + } catch (Exception ignore) {} + return sb.toString(); + } + + // Reflect the actual active panel mode in the selection (physical selections only). + private void syncSelectionToActiveMode() { + if (externalDisplay == null || resolutions.isEmpty()) return; + Display.Mode active; + try { + active = externalDisplay.getMode(); + } catch (Exception e) { + return; // display may be invalidated mid-change + } + if (active == null) return; + for (int i = 0; i < resolutions.size(); i++) { + ResEntry r = resolutions.get(i); + if (r.physical && r.w == active.getPhysicalWidth() && r.h == active.getPhysicalHeight()) { + selectedResolutionIndex = i; + break; + } + } + // Keep the glasses' persisted rate; don't snap back to the panel's transient 60Hz boot mode. + if (!isVitureSink()) selectedRefreshIndex = closestRateIndex(round1(active.getRefreshRate())); + } + + // ── Discovery ────────────────────────────────────────────────────────── + + public boolean hasExternalDisplay() { + return findExternalDisplay() != null; + } + + public boolean isSwapActive() { + return swapActive; + } + + public Display findExternalDisplay() { + if (displayManager == null) return null; + Display[] presentationDisplays = + displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION); + for (Display d : presentationDisplays) { + if (d != null && d.getDisplayId() != Display.DEFAULT_DISPLAY && d.isValid()) return d; + } + for (Display d : displayManager.getDisplays()) { + if (d != null && d.getDisplayId() != Display.DEFAULT_DISPLAY && d.isValid() + && (d.getFlags() & Display.FLAG_PRESENTATION) != 0) { + return d; + } + } + return null; + } + + public String getDisplayName() { + return describeDisplay(externalDisplay); + } + + public String getAvailableDisplayName() { + return describeDisplay(findExternalDisplay()); + } + + // Prefer the EDID product name (the actual model); fall back to the framework display name. + private static String describeDisplay(Display d) { + if (d == null) return ""; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + try { + DeviceProductInfo info = d.getDeviceProductInfo(); + if (info != null) { + String product = info.getName(); + if (product != null && !product.trim().isEmpty()) return product.trim(); + } + } catch (Exception ignore) {} + } + String name = d.getName(); + return name != null ? name.trim() : ""; + } + + // True when the active sink is Viture glasses (USB control up, or the EDID product name says VITURE). + private boolean isVitureSink() { + if (viture.isConnected()) return true; + String name = describeDisplay(externalDisplay); + return !name.isEmpty() && name.toUpperCase(java.util.Locale.ROOT).contains("VITURE"); + } + + // ── Swap / restore ───────────────────────────────────────────────────── + + public void enterSwap() { + if (swapActive) return; + Display d = findExternalDisplay(); + if (d == null) return; + externalDisplay = d; + + // Capture the phone surface size before reparenting, so swap-back can restore it exactly. + savedPhoneViewWidth = phoneFrame != null && phoneFrame.getWidth() > 0 + ? phoneFrame.getWidth() : gameView.getWidth(); + savedPhoneViewHeight = phoneFrame != null && phoneFrame.getHeight() > 0 + ? phoneFrame.getHeight() : gameView.getHeight(); + + GamePresentation pres = new GamePresentation(activity, d); + pres.setOnDismissListener(dialog -> { + if (swapActive) { + exitSwap(); + callbacks.onExternalDisplayDisconnected(); + } + }); + try { + pres.show(); + } catch (Exception e) { + // InvalidDisplayException / BadTokenException — abort the swap cleanly instead of crashing. + Log.w(TAG, "Could not show presentation on external display", e); + pres.setOnDismissListener(null); + try { pres.dismiss(); } catch (Exception ignore) {} + externalDisplay = null; + return; + } + presentation = pres; + + // Move only the render surface; controls/menu/HUD remain on the phone. + if (gameView.getParent() instanceof ViewGroup) { + ((ViewGroup) gameView.getParent()).removeView(gameView); + } + pres.getContainer().addView(gameView, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + swapActive = true; + renderActive = false; + fillMode = ViewTransformation.FILL_MODE_FIT; + rebuildModeTables(); + applyFillModeToRenderer(); + applyGameMode(); + applyHighRefreshToPhone(); // reduce phone touch lag + applyExternalPresentMode(); // non-blocking present so a slow sink can't stall the render thread + + // Glasses already under USB control: push the persisted mode (default 120Hz) now via the MCU. + if (viture.isConnected()) applyOutputMode(); + + callbacks.onSwapStateChanged(true); + } + + // Return the game to the phone and dismiss the presentation. Idempotent. + public void exitSwap() { + if (!swapActive && presentation == null) return; + boolean wasActive = swapActive; + swapActive = false; + renderActive = false; + panelScalerLocked = false; + + // Clear any render buffer so the phone surface follows the phone layout again. + fillMode = ViewTransformation.FILL_MODE_FIT; + VulkanRenderer renderer = gameView.getRenderer(); + try { gameView.getHolder().setSizeFromLayout(); } catch (Exception ignore) {} + if (renderer != null) { + renderer.setFillModeQuiet(ViewTransformation.FILL_MODE_FIT); + renderer.invalidateSurfaceSize(); + } + restorePhoneRefresh(); + restorePresentMode(); + + if (gameView.getParent() instanceof ViewGroup) { + ((ViewGroup) gameView.getParent()).removeView(gameView); + } + GamePresentation pres = presentation; + presentation = null; + if (pres != null) { + pres.setOnDismissListener(null); + try { pres.dismiss(); } catch (Exception ignore) {} + } + externalDisplay = null; + + // Re-add behind the on-screen controls (index 0 = lowest z-order). + if (gameView.getParent() == null && phoneFrame != null) { + phoneFrame.addView(gameView, 0, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + // Compose's empty AndroidView update={} won't re-measure the reparented view, so size it + // explicitly at the phone frame; the posted/delayed retries cover the async surface. + restorePhoneSurfaceSize(); + gameView.requestLayout(); + phoneFrame.requestLayout(); + mainHandler.post(this::restorePhoneSurfaceSize); + mainHandler.postDelayed(this::syncViewportToPhoneSurface, 250L); + mainHandler.postDelayed(this::syncViewportToPhoneSurface, 600L); + } + if (wasActive) callbacks.onSwapStateChanged(false); + } + + // Measure + layout the reparented SurfaceView at the phone frame's size and recompute the viewport. + private void restorePhoneSurfaceSize() { + if (swapActive || phoneFrame == null) return; + int w = phoneFrame.getWidth(); + int h = phoneFrame.getHeight(); + if (w <= 0 || h <= 0) { w = savedPhoneViewWidth; h = savedPhoneViewHeight; } + if (w <= 0 || h <= 0) return; + gameView.measure( + View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY)); + gameView.layout(0, 0, w, h); + VulkanRenderer r = gameView.getRenderer(); + if (r != null) r.forceViewportRecompute(w, h); + } + + private void syncViewportToPhoneSurface() { + if (swapActive) return; + VulkanRenderer r = gameView.getRenderer(); + if (r == null) return; + int w = gameView.getSurfaceWidth(); + int h = gameView.getSurfaceHeight(); + if (w <= 0 || h <= 0) { + w = phoneFrame != null ? phoneFrame.getWidth() : 0; + h = phoneFrame != null ? phoneFrame.getHeight() : 0; + } + if (w > 0 && h > 0) r.forceViewportRecompute(w, h); + } + + // ── Output tab: resolution / refresh / aspect / game mode ────────────── + + // Build the hybrid resolution list (physical modes ∪ render tiers) and refresh options. + private void rebuildModeTables() { + resolutions.clear(); + refreshOptions.clear(); + selectedResolutionIndex = 0; + selectedRefreshIndex = 0; + renderActive = false; + panelScalerLocked = false; + if (externalDisplay == null) return; + + Display.Mode current; + try { + current = externalDisplay.getMode(); + } catch (Exception e) { + current = null; + } + int nativeW = current != null ? current.getPhysicalWidth() : 0; + int nativeH = current != null ? current.getPhysicalHeight() : 0; + float panelAspect = (nativeW > 0 && nativeH > 0) ? (float) nativeW / nativeH : 16f / 9f; + + Display.Mode[] modes; + try { + modes = externalDisplay.getSupportedModes(); + } catch (Exception e) { + modes = new Display.Mode[0]; + } + + // Physical resolutions reported by the panel. + List physical = new ArrayList<>(); + for (Display.Mode m : modes) { + int w = m.getPhysicalWidth(); + int h = m.getPhysicalHeight(); + if (!containsRes(physical, w, h)) physical.add(new int[]{w, h}); + } + for (int[] p : physical) { + boolean isNative = p[0] == nativeW && p[1] == nativeH; + resolutions.add(new ResEntry(p[0], p[1], true, resLabel(p[0], p[1], isNative))); + } + + // Standard render tiers (aspect-matched), so single-mode sinks still get 4K/2K/1080/720/480. + int maxH = Math.max(2160, nativeH); + for (int tierH : STANDARD_TIER_HEIGHTS) { + if (tierH > maxH) continue; + int tierW = Math.round(tierH * panelAspect); + tierW -= (tierW & 1); // keep even + if (tierW <= 0 || containsResEntry(tierW, tierH)) continue; + resolutions.add(new ResEntry(tierW, tierH, false, resLabel(tierW, tierH, false))); + } + resolutions.sort((a, b) -> Long.compare((long) b.w * b.h, (long) a.w * a.h)); + + for (int i = 0; i < resolutions.size(); i++) { + ResEntry r = resolutions.get(i); + if (r.physical && r.w == nativeW && r.h == nativeH) { + selectedResolutionIndex = i; + break; + } + } + + // Detected rates first (so 59.94 wins over 60.0), then standard tiers. + for (Display.Mode m : modes) addRate(refreshOptions, round1(m.getRefreshRate())); + for (float r : STANDARD_REFRESH_RATES) addRate(refreshOptions, r); + refreshOptions.sort((a, b) -> Float.compare(b, a)); + float nativeRate = current != null ? round1(current.getRefreshRate()) : 60f; + // Glasses default to the persisted rate (120 out of the box); other sinks to their native rate. + selectedRefreshIndex = closestRateIndex( + isVitureSink() ? GlassesManager.INSTANCE.currentRefreshHz() : nativeRate); + + lastModesSignature = modesSignature(externalDisplay); + } + + public List getResolutionLabels() { + List out = new ArrayList<>(); + for (ResEntry r : resolutions) out.add(r.label); + return out; + } + + public int getSelectedResolutionIndex() { + return clampIndex(selectedResolutionIndex, resolutions.size()); + } + + public List getRefreshRateLabels() { + List out = new ArrayList<>(); + for (Float r : refreshOptions) out.add(Math.round(r) + " Hz"); + return out; + } + + public int getSelectedRefreshRateIndex() { + return clampIndex(selectedRefreshIndex, refreshOptions.size()); + } + + public void selectResolution(int index) { + if (index < 0 || index >= resolutions.size()) return; + selectedResolutionIndex = index; + applyOutputMode(); + } + + public void selectRefreshRate(int index) { + if (index < 0 || index >= refreshOptions.size()) return; + selectedRefreshIndex = index; + if (isVitureSink()) GlassesManager.INSTANCE.persistRefreshHz(Math.round(refreshOptions.get(index))); + applyOutputMode(); + } + + private float currentSelectedRefresh() { + if (refreshOptions.isEmpty()) return 0f; + return refreshOptions.get(clampIndex(selectedRefreshIndex, refreshOptions.size())); + } + + // Physical resolution with a matching mode switches the panel; otherwise drive the render buffer. + private void applyOutputMode() { + if (!swapActive || externalDisplay == null || presentation == null + || presentation.getWindow() == null || resolutions.isEmpty()) { + return; + } + ResEntry res = resolutions.get(clampIndex(selectedResolutionIndex, resolutions.size())); + float hz = currentSelectedRefresh(); + WindowManager.LayoutParams lp = presentation.getWindow().getAttributes(); + final int gen = ++modeRequestGen; + + // On Viture glasses the panel timing is driven over USB (Android may not even enumerate the + // mode), so force it via the MCU; the Android calls below then lock the Presentation onto it. + if (viture.isConnected()) viture.forceRefreshHz(Math.round(hz)); + + if (res.physical && !panelScalerLocked) { + Display.Mode best = bestPhysicalMode(res.w, res.h, hz); + if (best != null) { + clearRenderBuffer(); + lp.preferredRefreshRate = 0f; // must be 0 when a modeId is set + lp.preferredDisplayModeId = best.getModeId(); + presentation.getWindow().setAttributes(lp); + requestSurfaceFrameRate(best.getRefreshRate()); // reinforce on the surface + renderActive = false; + Log.i(TAG, "Physical mode " + res.w + "x" + res.h + "@" + + Math.round(best.getRefreshRate()) + " (modeId=" + best.getModeId() + ")"); + verifyPhysicalSwitch(best, gen); + return; + } + } + + // Render path: panel stays at native, the scaler maps our buffer onto it. + lp.preferredDisplayModeId = 0; + lp.preferredRefreshRate = hz > 0f ? hz : 0f; + presentation.getWindow().setAttributes(lp); + setRenderBuffer(res.w, res.h); + requestSurfaceFrameRate(hz); + renderActive = true; + Log.i(TAG, "Render buffer " + res.w + "x" + res.h + " @~" + Math.round(hz) + + "Hz (hardware-scaled to panel)"); + } + + private Display.Mode bestPhysicalMode(int w, int h, float hz) { + if (externalDisplay == null) return null; + Display.Mode best = null; + float bestDiff = Float.MAX_VALUE; + Display.Mode[] modes; + try { + modes = externalDisplay.getSupportedModes(); + } catch (Exception e) { + return null; + } + for (Display.Mode m : modes) { + if (m.getPhysicalWidth() == w && m.getPhysicalHeight() == h) { + float diff = Math.abs(m.getRefreshRate() - hz); + if (diff < bestDiff) { bestDiff = diff; best = m; } + } + } + return best; + } + + // After requesting a real switch, confirm the panel actually moved. Some phone display pipelines + // (e.g. OnePlus over USB-C/HDMI) silently hold the native timing and hardware-scale — even the + // system setUserPreferredDisplayMode can't move them. When that happens, latch into render scaling + // so the Output controls stay honest instead of implying a switch that never landed. + private void verifyPhysicalSwitch(Display.Mode requested, final int gen) { + final int wantW = requested.getPhysicalWidth(); + final int wantH = requested.getPhysicalHeight(); + final float wantHz = requested.getRefreshRate(); + mainHandler.postDelayed(() -> { + if (gen != modeRequestGen || !swapActive || externalDisplay == null || panelScalerLocked) { + return; // superseded by a newer selection, swap ended, or already known to scale + } + if (displayManager != null) { + Display fresh = displayManager.getDisplay(externalDisplay.getDisplayId()); + if (fresh != null) externalDisplay = fresh; + } + Display.Mode now; + try { + now = externalDisplay.getMode(); + } catch (Exception e) { + return; + } + if (now == null) return; + boolean moved = now.getPhysicalWidth() == wantW && now.getPhysicalHeight() == wantH + && Math.abs(now.getRefreshRate() - wantHz) < 1.5f; + if (moved) return; + panelScalerLocked = true; + Log.i(TAG, "Sink ignored mode switch; native " + now.getPhysicalWidth() + "x" + + now.getPhysicalHeight() + "@" + Math.round(now.getRefreshRate()) + + " held — using render scaling"); + applyOutputMode(); + callbacks.onSwapStateChanged(true); + }, 2500L); + } + + // True once the connected sink has been observed to ignore a real mode switch (phone is scaling). + public boolean isPanelScaling() { + return panelScalerLocked; + } + + // The panel's actual output mode, e.g. "3440 × 1440 · 60 Hz" — shown when the phone is scaling. + public String getPanelNativeSummary() { + if (externalDisplay == null) return ""; + try { + Display.Mode m = externalDisplay.getMode(); + if (m == null) return ""; + return m.getPhysicalWidth() + " × " + m.getPhysicalHeight() + + " · " + Math.round(m.getRefreshRate()) + " Hz"; + } catch (Exception e) { + return ""; + } + } + + private void setRenderBuffer(int w, int h) { + if (w <= 0 || h <= 0) return; + try { gameView.getHolder().setFixedSize(w, h); } catch (Exception ignore) {} + } + + private void clearRenderBuffer() { + try { gameView.getHolder().setSizeFromLayout(); } catch (Exception ignore) {} + } + + // Push the external surface toward a refresh rate (API 30+). FIXED_SOURCE + CHANGE_FRAME_RATE_ALWAYS + // is the most aggressive public lever — it requests a non-seamless switch to the panel's nearest + // matching mode rather than only a seamless one. Still a vote: a no-op if the panel can't produce it. + private void requestSurfaceFrameRate(float hz) { + if (hz <= 0f || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return; + try { + Surface s = gameView.getHolder().getSurface(); + if (s == null || !s.isValid()) return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + s.setFrameRate(hz, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + Surface.CHANGE_FRAME_RATE_ALWAYS); + } else { + s.setFrameRate(hz, Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE); + } + } catch (Exception ignore) {} + } + + // ── Game mode (HDMI ALLM / minimal post-processing) ──────────────────── + + public boolean isGameModeSupported() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || externalDisplay == null) return false; + try { + return externalDisplay.isMinimalPostProcessingSupported(); + } catch (Exception e) { + return false; + } + } + + public boolean isGameModeEnabled() { + return gameMode; + } + + public void setGameMode(boolean enabled) { + gameMode = enabled; + applyGameMode(); + } + + private void applyGameMode() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return; + if (presentation == null || presentation.getWindow() == null) return; + try { + if (isGameModeSupported()) { + presentation.getWindow().setPreferMinimalPostProcessing(gameMode); + } + } catch (Exception ignore) {} + } + + // ── Viture XR glasses (USB control, only when Viture glasses are connected) ── + + public boolean isVitureConnected() { + return viture.isConnected(); + } + + public String getVitureName() { + return viture.modelName(); + } + + public boolean vitureSupportsBrightness() { + return viture.supportsBrightness(); + } + + public boolean vitureSupportsFilm() { + return viture.supportsFilm(); + } + + public boolean vitureFilmStepped() { + return viture.filmIsStepped(); + } + + public boolean vitureSupports3D() { + return viture.supports3D(); + } + + public int getVitureBrightnessMax() { + return viture.brightnessMax(); + } + + public int getVitureBrightness() { + return GlassesManager.INSTANCE.currentBrightness(); + } + + public int getVitureFilm() { + return GlassesManager.INSTANCE.isSunblock() ? 1 : 0; + } + + public boolean isViture3D() { + return GlassesManager.INSTANCE.is3D(); + } + + public boolean vitureSupportsVolume() { + return viture.supportsVolume(); + } + + public int getVitureVolumeMax() { + return viture.volumeMax(); + } + + public int getVitureVolume() { + return GlassesManager.INSTANCE.currentVolume(); + } + + public void setVitureVolume(int level) { + GlassesManager.INSTANCE.setVolume(level); + } + + public void setVitureBrightness(int level) { + GlassesManager.INSTANCE.setBrightness(level); + } + + public void setVitureFilm(int level) { + GlassesManager.INSTANCE.setSunblock(level > 0); + } + + public void setViture3D(boolean enabled) { + GlassesManager.INSTANCE.set3D(enabled); + } + + // ── Phone refresh / present mode (touch-lag mitigation) ──────────────── + + // Keep the phone panel at its max refresh while an external display is attached. + private void applyHighRefreshToPhone() { + if (activity.getWindow() == null || displayManager == null) return; + Display def = displayManager.getDisplay(Display.DEFAULT_DISPLAY); + if (def == null) return; + float maxRate = 0f; + for (Display.Mode m : def.getSupportedModes()) maxRate = Math.max(maxRate, m.getRefreshRate()); + if (maxRate <= 0f) return; + WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); + savedPhoneRefreshRate = lp.preferredRefreshRate; + lp.preferredRefreshRate = maxRate; + activity.getWindow().setAttributes(lp); + } + + private void restorePhoneRefresh() { + if (activity.getWindow() == null) return; + WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); + lp.preferredRefreshRate = savedPhoneRefreshRate; + activity.getWindow().setAttributes(lp); + } + + private void applyExternalPresentMode() { + VulkanRenderer r = gameView.getRenderer(); + if (r == null) return; + savedPresentMode = r.getPresentMode(); + r.setPresentMode(VulkanRenderer.PRESENT_MODE_MAILBOX); + } + + private void restorePresentMode() { + VulkanRenderer r = gameView.getRenderer(); + if (r != null) r.setPresentMode(savedPresentMode); + } + + // ── Aspect ratio (fill mode) ─────────────────────────────────────────── + + public int getFillMode() { + return fillMode; + } + + public void selectFillMode(int mode) { + fillMode = mode; + applyFillModeToRenderer(); + } + + private void applyFillModeToRenderer() { + VulkanRenderer renderer = gameView.getRenderer(); + if (renderer != null) renderer.setFillMode(fillMode); + } + + // ── Small helpers ────────────────────────────────────────────────────── + + private static boolean containsRes(List list, int w, int h) { + for (int[] r : list) if (r[0] == w && r[1] == h) return true; + return false; + } + + private boolean containsResEntry(int w, int h) { + for (ResEntry r : resolutions) if (r.w == w && r.h == h) return true; + return false; + } + + private static void addRate(List list, float r) { + if (r <= 0f) return; + for (float e : list) if (Math.abs(e - r) < REFRESH_EPSILON) return; + list.add(r); + } + + private int closestRateIndex(float target) { + int best = 0; + float bestDiff = Float.MAX_VALUE; + for (int i = 0; i < refreshOptions.size(); i++) { + float diff = Math.abs(refreshOptions.get(i) - target); + if (diff < bestDiff) { bestDiff = diff; best = i; } + } + return best; + } + + private static String resLabel(int w, int h, boolean isNative) { + String base; + switch (h) { + case 2160: base = "2160p · 4K"; break; + case 1440: base = "1440p · 2K"; break; + case 1080: base = "1080p"; break; + case 720: base = "720p"; break; + case 480: base = "480p"; break; + default: base = w + " × " + h; break; + } + return isNative ? base + " · native" : base; + } + + private static int clampIndex(int index, int size) { + if (size <= 0) return 0; + return Math.max(0, Math.min(index, size - 1)); + } + + private static float round1(float v) { + return Math.round(v * 10f) / 10f; + } + + // Black full-screen presentation whose container holds the moved game view. + private static final class GamePresentation extends Presentation { + private FrameLayout container; + + GamePresentation(Context outerContext, Display display) { + super(outerContext, display); + } + + FrameLayout getContainer() { + return container; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + container = new FrameLayout(getContext()); + container.setBackgroundColor(Color.BLACK); + container.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + setContentView(container); + View decor = getWindow() != null ? getWindow().getDecorView() : null; + if (decor != null) { + decor.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); + } + } + } +} diff --git a/app/src/main/runtime/display/GlassesManager.kt b/app/src/main/runtime/display/GlassesManager.kt new file mode 100644 index 000000000..9b391b397 --- /dev/null +++ b/app/src/main/runtime/display/GlassesManager.kt @@ -0,0 +1,128 @@ +package com.winlator.cmod.runtime.display + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.CopyOnWriteArrayList + +// App-wide owner of the single Viture USB controller plus the persisted glasses settings, shared by +// the library (pre-container control) and the in-game external-display swap so only one component +// ever claims the USB interface. Settings set anywhere persist and re-apply on every (re)connect. +object GlassesManager { + + data class Settings( + val refreshHz: Int = 120, + val brightness: Int = -1, // -1 = full (100%) until the user picks a level + val volume: Int = -1, // -1 = full (100%) until the user picks a level + val sunblock: Boolean = true, // glasses ship with the film on; match that by default + val threeD: Boolean = false, + val renderHeight: Int = 0, // 0 = native panel resolution; otherwise a render-scaling height + ) + + fun interface Listener { fun onGlassesChanged(connectionChanged: Boolean) } + + private var viture: VitureGlasses? = null + private var prefs: SharedPreferences? = null + private val listeners = CopyOnWriteArrayList() + + private val _connected = MutableStateFlow(false) + val connected: StateFlow = _connected.asStateFlow() + private val _settings = MutableStateFlow(Settings()) + val settings: StateFlow = _settings.asStateFlow() + + @Synchronized + fun init(context: Context) { + if (viture != null) return + val app = context.applicationContext + prefs = app.getSharedPreferences("viture_glasses", Context.MODE_PRIVATE) + _settings.value = load() + viture = VitureGlasses(app).also { v -> + v.setConnectionListener { c -> onConnectionChanged(c) } + v.attach() + } + } + + fun glasses(): VitureGlasses? = viture + fun isConnected(): Boolean = viture?.isConnected() == true + fun modelName(): String = viture?.modelName() ?: "Viture" + fun supportsBrightness(): Boolean = viture?.supportsBrightness() == true + fun supportsVolume(): Boolean = viture?.supportsVolume() == true + fun supportsFilm(): Boolean = viture?.supportsFilm() == true + fun supports3D(): Boolean = viture?.supports3D() == true + fun brightnessMax(): Int = viture?.brightnessMax() ?: 8 + fun volumeMax(): Int = viture?.volumeMax() ?: 8 + + fun currentRefreshHz(): Int = _settings.value.refreshHz + fun currentBrightness(): Int = _settings.value.brightness.let { if (it >= 0) it else brightnessMax() } + fun currentVolume(): Int = _settings.value.volume.let { if (it >= 0) it else volumeMax() } + fun isSunblock(): Boolean = _settings.value.sunblock + fun is3D(): Boolean = _settings.value.threeD + fun currentRenderHeight(): Int = _settings.value.renderHeight + + fun addListener(l: Listener) { listeners.add(l) } + fun removeListener(l: Listener) { listeners.remove(l) } + + private fun onConnectionChanged(c: Boolean) { + _connected.value = c + if (c) applyAll() + notifyListeners(true) + } + + private fun applyAll() { + val s = _settings.value + viture?.let { v -> + v.forceRefreshHz(s.refreshHz) + v.setBrightness(if (s.brightness < 0) v.brightnessMax() else s.brightness) + v.setVolume(if (s.volume < 0) v.volumeMax() else s.volume) + v.setFilm(if (s.sunblock) 1 else 0) + if (s.threeD) v.set3D(true) + } + } + + fun setRefreshHz(hz: Int) { update { it.copy(refreshHz = hz) }; viture?.forceRefreshHz(hz) } + fun persistRefreshHz(hz: Int) { update { it.copy(refreshHz = hz) } } // caller already applied the mode + fun setBrightness(value: Int) { update { it.copy(brightness = value) }; viture?.setBrightness(value) } + fun setVolume(value: Int) { update { it.copy(volume = value) }; viture?.setVolume(value) } + fun setSunblock(on: Boolean) { update { it.copy(sunblock = on) }; viture?.setFilm(if (on) 1 else 0) } + fun set3D(on: Boolean) { + update { it.copy(threeD = on) } + if (on) viture?.set3D(true) else viture?.forceRefreshHz(currentRefreshHz()) + } + fun setRenderHeight(height: Int) { update { it.copy(renderHeight = height) } } + + private fun update(transform: (Settings) -> Settings) { + val next = transform(_settings.value) + _settings.value = next + save(next) + notifyListeners(false) + } + + private fun notifyListeners(connectionChanged: Boolean) { + listeners.forEach { it.onGlassesChanged(connectionChanged) } + } + + private fun load(): Settings { + val p = prefs ?: return Settings() + return Settings( + p.getInt("refreshHz", 120), + p.getInt("brightness", -1), + p.getInt("volume", -1), + p.getBoolean("sunblock", true), + p.getBoolean("threeD", false), + p.getInt("renderHeight", 0), + ) + } + + private fun save(s: Settings) { + prefs?.edit()?.apply { + putInt("refreshHz", s.refreshHz) + putInt("brightness", s.brightness) + putInt("volume", s.volume) + putBoolean("sunblock", s.sunblock) + putBoolean("threeD", s.threeD) + putInt("renderHeight", s.renderHeight) + }?.apply() + } +} diff --git a/app/src/main/runtime/display/PerformanceHud.kt b/app/src/main/runtime/display/PerformanceHud.kt new file mode 100644 index 000000000..1c73a400e --- /dev/null +++ b/app/src/main/runtime/display/PerformanceHud.kt @@ -0,0 +1,204 @@ +package com.winlator.cmod.runtime.display + +import androidx.compose.foundation.Canvas +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.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +// Single source of truth for the performance HUD: FrameRating pushes live values each tick and the +// XServer menu pushes which elements are enabled, so the in-game overlay and the phone gauges always +// show the same set. Element indices match FrameRating: 0 FPS, 1 renderer, 2 GPU, 3 CPU, 4 RAM, +// 5 battery+temp, 6 frametime. +object PerformanceHudState { + data class Snapshot( + val enabled: BooleanArray = BooleanArray(7), + val fps: Float = 0f, + val frametimeMs: Float = 0f, + val gpuLoad: Int = -1, + val cpuPercent: Int = -1, + val ramPercent: Int = -1, + val batteryWatts: Float = 0f, + val tempC: Int = -1, + val renderer: String = "", + ) + + private val _state = MutableStateFlow(Snapshot()) + val state: StateFlow = _state.asStateFlow() + private val _visible = MutableStateFlow(false) + val visible: StateFlow = _visible.asStateFlow() + + @JvmStatic + fun setVisible(v: Boolean) { _visible.value = v } + + @JvmStatic + fun updateEnabled(enabled: BooleanArray) { + _state.value = _state.value.copy(enabled = enabled.copyOf()) + } + + @JvmStatic + fun updateValues( + fps: Float, frametimeMs: Float, gpuLoad: Int, cpuPercent: Int, + ramPercent: Int, batteryWatts: Float, tempC: Int, renderer: String, + ) { + _state.value = _state.value.copy( + fps = fps, frametimeMs = frametimeMs, gpuLoad = gpuLoad, cpuPercent = cpuPercent, + ramPercent = ramPercent, batteryWatts = batteryWatts, tempC = tempC, renderer = renderer, + ) + } +} + +private val HudAccent = Color(0xFF1A9FFF) +private val HudGood = Color(0xFF35D0BA) +private val HudWarn = Color(0xFFFFB020) +private val HudBad = Color(0xFFFF5A5A) +private val HudText = Color(0xFFF0F4FF) +private val HudSub = Color(0xFF7A8FA8) +private val HudTrack = Color(0x33FFFFFF) + +private data class GaugeSpec( + val label: String, + val value: String, + val fraction: Float, + val color: Color, + val sublabel: String? = null, + val sublabelColor: Color = HudSub, +) + +@Composable +fun PerformanceHudOverlay(modifier: Modifier = Modifier) { + val s by PerformanceHudState.state.collectAsState() + // A gauge stays on screen for as long as its element is enabled; a momentarily-unavailable + // value shows N/A rather than dropping the gauge (which would make the row jump around). + val gauges = ArrayList(8) + if (s.enabled.getOrElse(0) { false }) { + gauges.add(GaugeSpec("FPS", s.fps.toInt().toString(), s.fps / 120f, HudAccent)) + } + if (s.enabled.getOrElse(2) { false }) { + gauges.add(GaugeSpec("GPU", pctText(s.gpuLoad), pctFraction(s.gpuLoad), loadColor(maxOf(s.gpuLoad, 0)))) + } + if (s.enabled.getOrElse(3) { false }) { + gauges.add(GaugeSpec("CPU", pctText(s.cpuPercent), pctFraction(s.cpuPercent), loadColor(maxOf(s.cpuPercent, 0)))) + } + if (s.enabled.getOrElse(4) { false }) { + gauges.add(GaugeSpec("RAM", pctText(s.ramPercent), pctFraction(s.ramPercent), loadColor(maxOf(s.ramPercent, 0)))) + } + if (s.enabled.getOrElse(6) { false }) { + gauges.add(GaugeSpec("ms", String.format("%.1f", s.frametimeMs), 1f - (s.frametimeMs / 33.3f), HudGood)) + } + if (s.enabled.getOrElse(5) { false }) { + // Battery + temperature is a single HUD element: watts is the gauge value, temp the sublabel. + gauges.add(GaugeSpec( + "WATT", if (s.batteryWatts >= 0f) String.format("%.1f", s.batteryWatts) else "N/A", + if (s.batteryWatts >= 0f) s.batteryWatts / 12f else 0f, HudAccent, + sublabel = if (s.tempC >= 0) "${s.tempC}°C" else null, + sublabelColor = if (s.tempC >= 0) tempColor(s.tempC) else HudSub, + )) + } + val showRenderer = s.enabled.getOrElse(1) { false } && s.renderer.isNotEmpty() + Box( + modifier = modifier.fillMaxSize().background(Color(0xF00A0D13)), + ) { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 16.dp) + .padding(bottom = if (showRenderer) 48.dp else 0.dp), + verticalArrangement = Arrangement.spacedBy(18.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + gauges.chunked(3).forEach { rowGauges -> + Row(horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.CenterHorizontally)) { + rowGauges.forEach { g -> HudGauge(g.label, g.value, g.fraction, g.color, g.sublabel, g.sublabelColor) } + } + } + } + if (showRenderer) { + // Pinned just above the bottom edge so it is never clipped, regardless of gauge count. + Text( + s.renderer, + color = HudAccent, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 18.dp) + .clip(RoundedCornerShape(9.dp)) + .background(Color(0x1A1A9FFF)) + .padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + } +} + +private fun pctText(v: Int): String = if (v >= 0) "$v%" else "N/A" + +private fun pctFraction(v: Int): Float = if (v >= 0) v / 100f else 0f + +private fun loadColor(pct: Int): Color = + if (pct >= 90) HudBad else if (pct >= 70) HudWarn else HudGood + +private fun tempColor(c: Int): Color = + if (c >= 45) HudBad else if (c >= 40) HudWarn else HudGood + +@Composable +private fun HudGauge( + label: String, + valueText: String, + fraction: Float, + accent: Color, + sublabel: String? = null, + sublabelColor: Color = HudSub, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(86.dp)) { + Canvas(modifier = Modifier.fillMaxSize()) { + val stroke = 7.dp.toPx() + val inset = stroke / 2f + val arcSize = Size(size.width - stroke, size.height - stroke) + drawArc( + color = HudTrack, startAngle = 135f, sweepAngle = 270f, useCenter = false, + topLeft = Offset(inset, inset), size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + drawArc( + color = accent, startAngle = 135f, sweepAngle = 270f * fraction.coerceIn(0f, 1f), + useCenter = false, topLeft = Offset(inset, inset), size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + } + Text(valueText, color = HudText, fontSize = 21.sp, fontWeight = FontWeight.Bold) + } + Text(label, color = HudSub, fontSize = 13.sp, fontWeight = FontWeight.Medium) + if (sublabel != null) { + Text(sublabel, color = sublabelColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } + } +} diff --git a/app/src/main/runtime/display/VitureGlasses.kt b/app/src/main/runtime/display/VitureGlasses.kt new file mode 100644 index 000000000..441616ff2 --- /dev/null +++ b/app/src/main/runtime/display/VitureGlasses.kt @@ -0,0 +1,373 @@ +package com.winlator.cmod.runtime.display + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import android.util.Log + +// Controls Viture XR glasses over their USB MCU interface (protocol RE'd from SpaceWalker's +// libglasses-internal.so): force panel refresh/3D and set brightness/electrochromic shade. Active +// only when real Viture glasses (vendor 0x35CA) are connected; TVs/monitors use the Android path. +class VitureGlasses(private val context: Context) { + + fun interface ConnectionListener { + fun onVitureConnectionChanged(connected: Boolean) + } + + companion object { + private const val TAG = "VitureGlasses" + const val VENDOR_ID = 0x35CA + private const val ACTION_PERMISSION = "com.winlator.cmod.USB_PERMISSION_VITURE" + + // Product ids grouped by the command dialect the firmware speaks (from the decompiled tables). + private val PID_ONE = intArrayOf(0x1011, 0x1013, 0x1015, 0x1017, 0x101b) + private val PID_BEAST = intArrayOf(0x1201, 0x1211) + + // MCU command ids (msgId). Display/3D are the high-confidence path; the rest vary per model. + private const val MSG_DISPLAY = 0x0008 + private const val MSG_DISPLAY_BEAST = 0x0124 + private const val MSG_BRIGHTNESS = 0x0006 + private const val MSG_BRIGHTNESS_BEAST = 0x0122 + private const val MSG_FILM_BINARY = 0x000E // One: clear/dark only + private const val MSG_FILM_STEPPED = 0x0330 // newer: 0..8 steps + private const val MSG_VOLUME = 0x0033 + private const val MSG_VOLUME_BEAST = 0x0201 + + // Display-mode bytes the firmware accepts (1080p family). + const val MODE_1080P_60 = 0x31 + const val MODE_3840X1080_60 = 0x32 // side-by-side / 3D + const val MODE_1080P_90 = 0x33 + const val MODE_1080P_120 = 0x34 + + private const val PACKET_SIZE = 64 + + // CRC-16/XMODEM table (poly 0x1021, init 0) — matches the table at 0x1273f2 in the binary. + private val CRC_TABLE = IntArray(256).also { t -> + for (i in 0 until 256) { + var c = i shl 8 + repeat(8) { c = if (c and 0x8000 != 0) (c shl 1) xor 0x1021 else c shl 1 } + t[i] = c and 0xFFFF + } + } + } + + private val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + private var connection: UsbDeviceConnection? = null + private var commandInterface: UsbInterface? = null + private var controlInterface: UsbInterface? = null + private var outEndpoint: UsbEndpoint? = null + private var inEndpoint: UsbEndpoint? = null // MCU INT-IN (ep 0x82) — state reports / verify + private var productId = 0 + private var permissionPending = false + private var listener: ConnectionListener? = null + private var receiversRegistered = false + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(c: Context, intent: Intent) { + when (intent.action) { + ACTION_PERMISSION -> { + permissionPending = false + val device = intent.deviceExtra() + if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) && device != null) { + openDevice(device) + } + } + UsbManager.ACTION_USB_DEVICE_ATTACHED -> attach() + UsbManager.ACTION_USB_DEVICE_DETACHED -> + if (intent.deviceExtra()?.vendorId == VENDOR_ID) closeConnection() + } + } + } + + fun setConnectionListener(l: ConnectionListener?) { + listener = l + } + + // Detect connected Viture glasses and open the command interface (requesting USB permission if needed). + fun attach() { + registerReceivers() + if (connection != null) return + val device = findVitureDevice() ?: return + productId = device.productId + if (usbManager.hasPermission(device)) { + openDevice(device) + } else if (!permissionPending) { + requestPermission(device) + } + } + + fun detach() { + closeConnection() + if (receiversRegistered) { + try { context.unregisterReceiver(receiver) } catch (ignore: Exception) {} + receiversRegistered = false + } + } + + private fun closeConnection() { + val wasConnected = connection != null + connection?.let { c -> + commandInterface?.let { c.releaseInterface(it) } + controlInterface?.let { c.releaseInterface(it) } + c.close() + } + connection = null + commandInterface = null + controlInterface = null + outEndpoint = null + inEndpoint = null + if (wasConnected) listener?.onVitureConnectionChanged(false) + } + + fun isConnected(): Boolean = connection != null && outEndpoint != null + + fun modelName(): String = when { + productId == 0 -> "Viture" + isBeast() -> "Viture Beast" + isOne() -> "Viture One" + else -> "Viture" + } + + // ── Capabilities (all gated on a real connection) ────────────────────── + + fun supportsBrightness(): Boolean = isConnected() + fun supports3D(): Boolean = isConnected() + fun supportsFilm(): Boolean = isConnected() + // Only Beast uses the stepped 0..8 film (msgId 0x0330); every other model is binary on/off (0x000E). + fun filmIsStepped(): Boolean = isBeast() + fun brightnessMax(): Int = if (isOne()) 6 else 8 + + // ── High-level controls ──────────────────────────────────────────────── + + /** Force the panel timing. hz 120/90/60 maps to the 1080p mode bytes (0x34/0x33/0x31). */ + fun forceRefreshHz(hz: Int): Boolean { + val mode = when { + hz >= 120 -> MODE_1080P_120 + hz >= 90 -> MODE_1080P_90 + else -> MODE_1080P_60 + } + return setDisplayMode(mode) + } + + fun setDisplayMode(modeByte: Int): Boolean { + val msg = if (isBeast()) MSG_DISPLAY_BEAST else MSG_DISPLAY + return send(msg, byteArrayOf(modeByte.toByte())) + } + + /** 3D/SBS is the same display-mode command (0x32 = side-by-side on, 0x31 = off). */ + fun set3D(enabled: Boolean): Boolean = + setDisplayMode(if (enabled) MODE_3840X1080_60 else MODE_1080P_60) + + fun setBrightness(level: Int): Boolean { + val msg = if (isBeast()) MSG_BRIGHTNESS_BEAST else MSG_BRIGHTNESS + val v = level.coerceIn(0, brightnessMax()) + return send(msg, byteArrayOf(v.toByte(), 0)) + } + + // Electrochromic shade — stepped 0..8 (newer) or binary 0/1 (One). + fun setFilm(level: Int): Boolean { + return if (filmIsStepped()) { + send(MSG_FILM_STEPPED, byteArrayOf(level.coerceIn(0, 8).toByte())) + } else { + send(MSG_FILM_BINARY, byteArrayOf(if (level > 0) 1 else 0)) + } + } + + // Glasses hardware volume (0..15 on Beast, else 0..8). + fun setVolume(level: Int): Boolean { + val msg = if (isBeast()) MSG_VOLUME_BEAST else MSG_VOLUME + return send(msg, byteArrayOf(level.coerceIn(0, volumeMax()).toByte(), 0)) + } + + fun supportsVolume(): Boolean = isConnected() + fun volumeMax(): Int = if (isBeast()) 15 else 8 + + // ── USB plumbing ─────────────────────────────────────────────────────── + + private fun findVitureDevice(): UsbDevice? = + usbManager.deviceList.values.firstOrNull { it.vendorId == VENDOR_ID } + + private fun registerReceivers() { + if (receiversRegistered) return + val filter = IntentFilter(ACTION_PERMISSION).apply { + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(receiver, filter) + } + receiversRegistered = true + } + + private fun requestPermission(device: UsbDevice) { + registerReceivers() + permissionPending = true + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 + val pi = PendingIntent.getBroadcast(context, 0, Intent(ACTION_PERMISSION).setPackage(context.packageName), flags) + usbManager.requestPermission(device, pi) + } + + private fun openDevice(device: UsbDevice) { + logInterfaces(device) + val cmd = findCommandInterface(device) ?: run { + Log.w(TAG, "No OUT endpoint found on ${device.productId.toHex16()}") + return + } + val conn = usbManager.openDevice(device) ?: run { Log.w(TAG, "openDevice failed"); return } + if (!conn.claimInterface(cmd.first, true)) { + conn.close() + Log.w(TAG, "claimInterface ${cmd.first.id} failed") + return + } + // CDC-Data (class 10) command channels usually need the paired CDC control interface claimed + // and DTR/RTS asserted (SET_CONTROL_LINE_STATE) before the bulk pipe carries data. + var ctrl: UsbInterface? = null + if (cmd.first.interfaceClass == 10) { + ctrl = findInterfaceByClass(device, 2) + if (ctrl != null && conn.claimInterface(ctrl, true)) { + conn.controlTransfer(0x21, 0x22, 0x03, ctrl.id, null, 0, 200) + } + } + connection = conn + commandInterface = cmd.first + controlInterface = ctrl + outEndpoint = cmd.second + inEndpoint = findInEndpoint(cmd.first) + productId = device.productId + Log.i(TAG, "Viture ${modelName()} opened: pid=${productId.toHex16()} cmdIface=${cmd.first.id} class=${cmd.first.interfaceClass} outEp=0x%02X(%s) ctrlIface=%s" + .format(cmd.second.address, epType(cmd.second.type), ctrl?.id?.toString() ?: "-")) + listener?.onVitureConnectionChanged(true) + } + + // Commands go to the MCU, which is the highest-numbered HID interface with an interrupt-OUT + // endpoint (the lower-numbered HID interface is the IMU). Falls back to bulk/any OUT otherwise. + private fun findCommandInterface(device: UsbDevice): Pair? { + var hidMcu: Pair? = null + var bulk: Pair? = null + var intr: Pair? = null + for (i in 0 until device.interfaceCount) { + val intf = device.getInterface(i) + for (e in 0 until intf.endpointCount) { + val ep = intf.getEndpoint(e) + if (ep.direction != UsbConstants.USB_DIR_OUT) continue + when (ep.type) { + UsbConstants.USB_ENDPOINT_XFER_INT -> { + if (intf.interfaceClass == UsbConstants.USB_CLASS_HID && + (hidMcu == null || intf.id > hidMcu!!.first.id) + ) hidMcu = intf to ep + if (intr == null) intr = intf to ep + } + UsbConstants.USB_ENDPOINT_XFER_BULK -> if (bulk == null) bulk = intf to ep + } + } + } + return hidMcu ?: bulk ?: intr + } + + private fun findInterfaceByClass(device: UsbDevice, cls: Int): UsbInterface? { + for (i in 0 until device.interfaceCount) { + val intf = device.getInterface(i) + if (intf.interfaceClass == cls) return intf + } + return null + } + + // The command interface's interrupt-IN endpoint (ep 0x82) — carries MCU state reports. + private fun findInEndpoint(intf: UsbInterface): UsbEndpoint? { + for (e in 0 until intf.endpointCount) { + val ep = intf.getEndpoint(e) + if (ep.direction == UsbConstants.USB_DIR_IN) return ep + } + return null + } + + // Read one MCU report (FF FD | crc | len | 0000 | seq | msgId | flag | value) if one is queued. + fun readState(timeoutMs: Int = 200): ByteArray? { + val conn = connection ?: return null + val ep = inEndpoint ?: return null + val buf = ByteArray(PACKET_SIZE) + val n = conn.bulkTransfer(ep, buf, buf.size, timeoutMs) + return if (n > 0) buf.copyOf(n) else null + } + + private fun logInterfaces(device: UsbDevice) { + for (i in 0 until device.interfaceCount) { + val intf = device.getInterface(i) + val eps = (0 until intf.endpointCount).joinToString { + val ep = intf.getEndpoint(it) + "0x%02X/%s/%s".format(ep.address, if (ep.direction == UsbConstants.USB_DIR_OUT) "OUT" else "IN", epType(ep.type)) + } + Log.i(TAG, "iface ${intf.id} class=${intf.interfaceClass} eps=[$eps]") + } + } + + private fun epType(t: Int): String = when (t) { + UsbConstants.USB_ENDPOINT_XFER_BULK -> "BULK" + UsbConstants.USB_ENDPOINT_XFER_INT -> "INT" + UsbConstants.USB_ENDPOINT_XFER_ISOC -> "ISO" + else -> "CTRL" + } + + // Build the V1 packet (FF FE | CRC16 | len | 8-byte header | msgId | 00 00 | payload) and send it. + private fun send(msgId: Int, payload: ByteArray): Boolean { + val conn = connection + val ep = outEndpoint + if (conn == null || ep == null) { + Log.w(TAG, "send 0x%04X dropped — not connected".format(msgId)) + return false + } + val pkt = buildPacket(msgId, payload) + val n = conn.bulkTransfer(ep, pkt, pkt.size, 250) + Log.i(TAG, "send msgId=0x%04X payload=%s -> %d".format(msgId, payload.toHex(), n)) + return n >= 0 + } + + private fun buildPacket(msgId: Int, payload: ByteArray): ByteArray { + val buf = ByteArray(PACKET_SIZE) + buf[0] = 0xFF.toByte() + buf[1] = 0xFE.toByte() + val len = if (payload.isEmpty()) 0x0C else payload.size + 0x0C + buf[4] = (len and 0xFF).toByte() + buf[5] = ((len shr 8) and 0xFF).toByte() + buf[14] = (msgId and 0xFF).toByte() + buf[15] = ((msgId shr 8) and 0xFF).toByte() + if (payload.isNotEmpty()) System.arraycopy(payload, 0, buf, 18, payload.size) + val crc = crc16(buf, 4, len + 2) + buf[2] = (crc and 0xFF).toByte() + buf[3] = ((crc shr 8) and 0xFF).toByte() + return buf + } + + private fun crc16(data: ByteArray, offset: Int, length: Int): Int { + var crc = 0 + for (i in offset until offset + length) { + val b = data[i].toInt() and 0xFF + crc = (CRC_TABLE[(b xor (crc ushr 8)) and 0xFF] xor (crc shl 8)) and 0xFFFF + } + return crc + } + + private fun isOne(): Boolean = productId in PID_ONE + private fun isBeast(): Boolean = productId in PID_BEAST + + private fun Intent.deviceExtra(): UsbDevice? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + + private fun Int.toHex16(): String = "0x%04X".format(this) + private fun ByteArray.toHex(): String = joinToString("") { "%02X".format(it) } +} diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index d6e9ed01c..deeba7fac 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -269,6 +269,10 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { private ImageFs imageFs; private FrameRating frameRating = null; private boolean effectiveShowFPS = false; + // Phone gauge HUD (rendered by the Compose host) shown with touch controls disabled while a + // physical controller + external display are active. + private boolean controllerHudMode = false; + private android.hardware.input.InputManager.InputDeviceListener hudControllerListener; private boolean isTapToClickEnabled = true; private int runtimeFpsLimit = 0; private String lastRendererName = "Vulkan"; @@ -397,6 +401,9 @@ public boolean isInputSuspended() { private boolean gyroscopeCardExpanded = false; private XServerDrawerStateHolder drawerStateHolder; private XServerDrawerActionListener drawerActionListener; + private ExternalDisplayController externalDisplayController; + private com.winlator.cmod.runtime.display.recording.GameRecorder screenRecorder; + private int savedRenderMode = XServerSurfaceView.RENDERMODE_WHEN_DIRTY; private Timer taskManagerTimer; private final ArrayList taskManagerAccum = new ArrayList<>(); private boolean taskManagerCpuExpanded = false; @@ -1554,7 +1561,7 @@ public void onUpdateWindowContent(Window window) { @Override public void onMapWindow(Window window) { assignTaskAffinity(window); - if (effectiveShowFPS && frameRating != null) { + if ((effectiveShowFPS || controllerHudMode) && frameRating != null) { syncFrameRatingWithExistingWindows(); } } @@ -2342,6 +2349,8 @@ public void onResume() { if (taskManagerPaneVisible && taskManagerTimer == null) { startTaskManagerPolling(); } + + if (externalDisplayController != null) externalDisplayController.start(); } @Override @@ -2380,6 +2389,8 @@ public void onPause() { if (winHandler != null) winHandler.setOnGetProcessInfoListener(null); taskManagerAccum.clear(); } + + if (externalDisplayController != null) externalDisplayController.stop(); } @Override @@ -3678,6 +3689,20 @@ private static boolean wnLauncherLogContains(File log, String marker) { @Override protected void onDestroy() { activityDestroyed.set(true); + // Finalize any in-progress recording before the renderer tears down. + if (screenRecorder != null && screenRecorder.isRecording()) { + stopScreenRecording(); + } + if (hudControllerListener != null) { + android.hardware.input.InputManager im = + (android.hardware.input.InputManager) getSystemService(Context.INPUT_SERVICE); + if (im != null) im.unregisterInputDeviceListener(hudControllerListener); + hudControllerListener = null; + } + if (externalDisplayController != null) { + externalDisplayController.release(); + externalDisplayController = null; + } if (isDependencyInstall) { com.winlator.cmod.runtime.content.component.DependencyInstallBridge.complete(dependencyExitStatus); } @@ -3964,6 +3989,8 @@ private void renderDrawerMenu() { xServerView != null && xServerView.getRenderer() != null && xServerView.getRenderer().isFullscreen(), RefreshRateUtils.getMaxSupportedRefreshRate(this), isRefactorSizeEnabled, + screenRecorder != null && screenRecorder.isRecording(), + buildRecordConfig(), screenTouchMode, rtsGesturesEnabled, gestureProfileNames, @@ -3972,6 +3999,42 @@ private void renderDrawerMenu() { preferences.getFloat("screen_touch_rs_sensitivity", 1.25f) ); + // Always-present "Output" tab (live controls while swapped, otherwise a Cast entry point). + if (externalDisplayController != null) { + boolean swapped = externalDisplayController.isSwapActive(); + state = XServerDrawerMenuKt.withOutputState( + state, + swapped, + swapped ? externalDisplayController.getDisplayName() + : externalDisplayController.getAvailableDisplayName(), + externalDisplayController.getResolutionLabels(), + externalDisplayController.getSelectedResolutionIndex(), + externalDisplayController.getRefreshRateLabels(), + externalDisplayController.getSelectedRefreshRateIndex(), + externalDisplayController.getFillMode(), + externalDisplayController.isGameModeSupported(), + externalDisplayController.isGameModeEnabled(), + externalDisplayController.isPanelScaling(), + externalDisplayController.getPanelNativeSummary(), + externalDisplayController.hasExternalDisplay()); + if (externalDisplayController.isVitureConnected()) { + state = XServerDrawerMenuKt.withVitureState( + state, + externalDisplayController.getVitureName(), + externalDisplayController.vitureSupportsBrightness(), + externalDisplayController.getVitureBrightness(), + externalDisplayController.getVitureBrightnessMax(), + externalDisplayController.vitureSupportsFilm(), + externalDisplayController.vitureFilmStepped(), + externalDisplayController.getVitureFilm(), + externalDisplayController.vitureSupports3D(), + externalDisplayController.isViture3D(), + externalDisplayController.vitureSupportsVolume(), + externalDisplayController.getVitureVolume(), + externalDisplayController.getVitureVolumeMax()); + } + } + if (drawerActionListener == null) { drawerActionListener = new XServerDrawerActionListener() { @Override @@ -3983,6 +4046,7 @@ public void onActionSelected(int itemId) { public void onHUDElementToggled(int index, boolean enabled) { hudElements[index] = enabled; if (frameRating != null) frameRating.toggleElement(index, enabled); + com.winlator.cmod.runtime.display.PerformanceHudState.updateEnabled(hudElements); saveHUDSettings(); renderDrawerMenu(); } @@ -4156,6 +4220,88 @@ public void onScreenEffectsCardExpandedChanged(boolean expanded) { renderDrawerMenu(); } + @Override + public void onOutputResolutionSelected(int index) { + if (externalDisplayController != null) { + externalDisplayController.selectResolution(index); + renderDrawerMenu(); + } + } + + @Override + public void onOutputRefreshRateSelected(int index) { + if (externalDisplayController != null) { + externalDisplayController.selectRefreshRate(index); + renderDrawerMenu(); + } + } + + @Override + public void onOutputAspectModeSelected(int mode) { + if (externalDisplayController != null) { + externalDisplayController.selectFillMode(mode); + renderDrawerMenu(); + } + } + + @Override + public void onOutputGameModeToggled(boolean enabled) { + if (externalDisplayController != null) { + externalDisplayController.setGameMode(enabled); + renderDrawerMenu(); + } + } + + @Override + public void onOutputVitureBrightness(int level) { + if (externalDisplayController != null) externalDisplayController.setVitureBrightness(level); + } + + @Override + public void onOutputVitureFilm(int level) { + if (externalDisplayController != null) { + externalDisplayController.setVitureFilm(level); + renderDrawerMenu(); + } + } + + @Override + public void onOutputViture3D(boolean enabled) { + if (externalDisplayController != null) { + externalDisplayController.setViture3D(enabled); + renderDrawerMenu(); + } + } + + @Override + public void onOutputVitureVolume(int level) { + if (externalDisplayController != null) externalDisplayController.setVitureVolume(level); + } + + @Override + public void onOutputReturnToPhone() { + if (externalDisplayController != null) { + externalDisplayController.exitSwap(); + renderDrawerMenu(); + } + } + + @Override + public void onOutputSwapToDisplay() { + if (externalDisplayController != null) { + externalDisplayController.enterSwap(); + renderDrawerMenu(); + android.widget.Toast.makeText(XServerDisplayActivity.this, + R.string.display_output_swapped_toast, + android.widget.Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onOutputCastClick() { + launchWirelessDisplayPicker(); + } + @Override public void onSGSREnabledChanged(boolean enabled) { boolean wasEnabled = sgsrEnabled; @@ -4635,6 +4781,11 @@ public void onLogsPaneVisibilityChanged(boolean visible) { public void onLogsShare() { shareLogStream(); } + + @Override + public void onRecordStart(int fpsIndex, int resolutionIndex, int quality, boolean recordUI) { + startRecordingWithSettings(fpsIndex, resolutionIndex, quality, recordUI); + } }; } @@ -5104,6 +5255,7 @@ private void loadHUDSettings() { Log.e("XServerDisplayActivity", "Failed to load HUD settings", e); } } + com.winlator.cmod.runtime.display.PerformanceHudState.updateEnabled(hudElements); } private void saveHUDSettings() { @@ -5258,6 +5410,11 @@ private boolean handleDrawerAction(int itemId) { } renderDrawerMenu(); break; + case R.id.main_menu_record: + // Starting is handled by the popup (onRecordStart); reaching here means stop. + if (screenRecorder != null && screenRecorder.isRecording()) stopScreenRecording(); + renderDrawerMenu(); + break; case R.id.main_menu_exit: closeDrawerMenu(); exit(); @@ -5266,6 +5423,256 @@ private boolean handleDrawerAction(int itemId) { return true; } + private static final int[] RECORD_FPS_TIERS = {30, 60, 90, 120, 144, 165}; + private static final int[] RECORD_RES_TIERS = {2160, 1440, 1080, 720}; // short-side heights + + /** FPS options the panel supports (ascending), e.g. a 120Hz panel → [30,60,90,120]. */ + private java.util.List recordFpsOptions() { + int max = Math.max(30, RefreshRateUtils.getMaxSupportedRefreshRate(this)); + java.util.List out = new java.util.ArrayList<>(); + for (int f : RECORD_FPS_TIERS) if (f <= max + 1) out.add(f); + if (out.isEmpty()) out.add(60); + return out; + } + + /** The native (full-res) capture short side — min of the composited image dimensions. */ + private int recordNativeShortSide() { + VulkanRenderer renderer = xServerView != null ? xServerView.getRenderer() : null; + int w = renderer != null ? renderer.getRecordWidth() : 0; + int h = renderer != null ? renderer.getRecordHeight() : 0; + if (w <= 0 || h <= 0) { + w = xServerView != null ? xServerView.getSurfaceWidth() : 0; + h = xServerView != null ? xServerView.getSurfaceHeight() : 0; + } + if (w <= 0 || h <= 0) return 0; + return Math.min(w, h); + } + + /** Resolution labels: Native first, then standard tiers strictly below the panel's native res. */ + private java.util.List recordResolutionLabels(int nativeShort) { + java.util.List out = new java.util.ArrayList<>(); + out.add("Native"); + if (nativeShort > 0) { + for (int t : RECORD_RES_TIERS) { + if (t < nativeShort) out.add(resTierLabel(t)); + } + } + return out; + } + + private static String resTierLabel(int shortSide) { + switch (shortSide) { + case 2160: return "4K"; + case 1440: return "2K"; + case 1080: return "1080p"; + case 720: return "720p"; + default: return shortSide + "p"; + } + } + + // Build the popup config with persisted selections mapped to current indices. + private RecordUiConfig buildRecordConfig() { + java.util.List fps = recordFpsOptions(); + int nativeShort = recordNativeShortSide(); + java.util.List res = recordResolutionLabels(nativeShort); + + int savedFps = preferences.getInt("record_fps", 60); + int fpsIndex = fps.indexOf(savedFps); + if (fpsIndex < 0) { // nearest supported + fpsIndex = 0; + int best = Integer.MAX_VALUE; + for (int i = 0; i < fps.size(); i++) { + int d = Math.abs(fps.get(i) - savedFps); + if (d < best) { best = d; fpsIndex = i; } + } + } + int resIndex = preferences.getInt("record_res_index", 0); + if (resIndex < 0 || resIndex >= res.size()) resIndex = 0; + int quality = preferences.getInt("record_quality", 2); + boolean recordUI = preferences.getBoolean("record_ui", false); + return new RecordUiConfig(fps, res, fpsIndex, resIndex, quality, recordUI); + } + + // Start recording with the popup's chosen settings, persisting them for next time. + private void startRecordingWithSettings(int fpsIndex, int resolutionIndex, int quality, boolean recordUI) { + if (screenRecorder != null && screenRecorder.isRecording()) return; + VulkanRenderer renderer = xServerView != null ? xServerView.getRenderer() : null; + if (renderer == null || xServerView == null) { + android.widget.Toast.makeText(this, R.string.session_record_failed, android.widget.Toast.LENGTH_SHORT).show(); + return; + } + + // Native composited size (swapchain extent), else the SurfaceView size. + int nativeW = renderer.getRecordWidth(); + int nativeH = renderer.getRecordHeight(); + if (nativeW <= 0 || nativeH <= 0) { + nativeW = xServerView.getSurfaceWidth(); + nativeH = xServerView.getSurfaceHeight(); + } + if (nativeW <= 0 || nativeH <= 0) { + android.widget.Toast.makeText(this, R.string.session_record_failed, android.widget.Toast.LENGTH_SHORT).show(); + return; + } + + java.util.List fpsOptions = recordFpsOptions(); + int nativeShort = Math.min(nativeW, nativeH); + java.util.List resLabels = recordResolutionLabels(nativeShort); + fpsIndex = Math.max(0, Math.min(fpsIndex, fpsOptions.size() - 1)); + resolutionIndex = Math.max(0, Math.min(resolutionIndex, resLabels.size() - 1)); + quality = Math.max(0, Math.min(quality, 2)); + + int fps = fpsOptions.get(fpsIndex); + + // Resolution: index 0 = Native; otherwise scale so the short side hits the chosen tier. + int encW = nativeW, encH = nativeH; + if (resolutionIndex > 0) { + int tierShort = tierShortForLabel(resLabels.get(resolutionIndex)); + if (tierShort > 0 && tierShort < nativeShort) { + double scale = (double) tierShort / nativeShort; + encW = (int) Math.round(nativeW * scale) & ~1; + encH = (int) Math.round(nativeH * scale) & ~1; + } + } + + int orientationHint = renderer.getRecordOrientationHint(); + int bitRate = recordBitrate(encW, encH, fps, quality); + + // Persist selections for next time. + preferences.edit() + .putInt("record_fps", fps) + .putInt("record_res_index", resolutionIndex) + .putInt("record_quality", quality) + .putBoolean("record_ui", recordUI) + .apply(); + + screenRecorder = new com.winlator.cmod.runtime.display.recording.GameRecorder(this); + android.view.Surface encoderSurface = screenRecorder.start(encW, encH, fps, orientationHint, bitRate); + if (encoderSurface == null || !renderer.startRecording(encoderSurface, fps, recordUI)) { + screenRecorder.stop(); + screenRecorder = null; + android.widget.Toast.makeText(this, R.string.session_record_failed, android.widget.Toast.LENGTH_SHORT).show(); + return; + } + // Force continuous frames while recording (renderer is otherwise on-demand). + savedRenderMode = xServerView.getRenderMode(); + xServerView.setRenderMode(XServerSurfaceView.RENDERMODE_CONTINUOUSLY); + if (recordUI) startRecordUiCapture(encW, encH, orientationHint); + renderDrawerMenu(); + android.widget.Toast.makeText(this, R.string.session_record_started, android.widget.Toast.LENGTH_SHORT).show(); + } + + // Record UI: snapshot the overlay views and feed them to the native composite. + private android.os.Handler recordUiHandler; + private Runnable recordUiSnapshot; + private android.graphics.Bitmap recordUiBitmap; + private int[] recordUiPixels; + private java.nio.ByteBuffer recordUiBuffer; + private int recordUiW, recordUiH, recordUiRotation; + + private void startRecordUiCapture(int w, int h, int orientationHint) { + stopRecordUiCapture(); + recordUiW = w; + recordUiH = h; + // Pre-rotate the upright screen-space UI into the recording's frame to match the game. + recordUiRotation = ((360 - (orientationHint % 360)) % 360); + try { + recordUiBitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888); + recordUiPixels = new int[w * h]; + recordUiBuffer = java.nio.ByteBuffer.allocateDirect(w * h * 4).order(java.nio.ByteOrder.LITTLE_ENDIAN); + } catch (Throwable t) { + Log.e("XServerDisplayActivity", "Record UI buffer alloc failed", t); + return; + } + recordUiHandler = new android.os.Handler(android.os.Looper.getMainLooper()); + recordUiSnapshot = new Runnable() { + @Override + public void run() { + if (screenRecorder == null || !screenRecorder.isRecording()) return; + snapshotRecordUi(); + if (recordUiHandler != null) recordUiHandler.postDelayed(this, 100); // ~10 fps overlay refresh + } + }; + recordUiHandler.post(recordUiSnapshot); + } + + private void snapshotRecordUi() { + try { + VulkanRenderer renderer = xServerView != null ? xServerView.getRenderer() : null; + View root = xServerView != null ? xServerView.getRootView() : null; + if (renderer == null || root == null || recordUiBitmap == null) return; + int sw = root.getWidth(); + int sh = root.getHeight(); + if (sw <= 0 || sh <= 0) return; + + recordUiBitmap.eraseColor(0); // transparent — the game area (SurfaceView) stays see-through + android.graphics.Canvas c = new android.graphics.Canvas(recordUiBitmap); + android.graphics.Matrix m = new android.graphics.Matrix(); + m.postRotate(recordUiRotation, sw / 2f, sh / 2f); + android.graphics.RectF r = new android.graphics.RectF(0, 0, sw, sh); + m.mapRect(r); + m.postTranslate(-r.left, -r.top); + float s = Math.min(recordUiW / r.width(), recordUiH / r.height()); + m.postScale(s, s); + c.setMatrix(m); + root.draw(c); + + recordUiBitmap.getPixels(recordUiPixels, 0, recordUiW, 0, 0, recordUiW, recordUiH); + recordUiBuffer.clear(); + recordUiBuffer.asIntBuffer().put(recordUiPixels); // little-endian int → BGRA bytes + recordUiBuffer.position(0); + renderer.updateRecordUITexture(recordUiBuffer, recordUiW, recordUiH); + } catch (Throwable t) { + Log.e("XServerDisplayActivity", "Record UI snapshot failed", t); + } + } + + private void stopRecordUiCapture() { + if (recordUiHandler != null && recordUiSnapshot != null) { + recordUiHandler.removeCallbacks(recordUiSnapshot); + } + recordUiHandler = null; + recordUiSnapshot = null; + if (recordUiBitmap != null) { + try { recordUiBitmap.recycle(); } catch (Exception ignore) {} + } + recordUiBitmap = null; + recordUiPixels = null; + recordUiBuffer = null; + } + + private static int tierShortForLabel(String label) { + switch (label) { + case "4K": return 2160; + case "2K": return 1440; + case "1080p": return 1080; + case "720p": return 720; + default: return 0; + } + } + + // Quality preset → bits-per-pixel·frame, then bitrate, clamped to a sane window. + private static int recordBitrate(int w, int h, int fps, int quality) { + double bpp; + switch (quality) { + case 0: bpp = 0.035; break; // Performance + case 1: bpp = 0.075; break; // Balance + default: bpp = 0.15; break; // Quality + } + long bps = (long) (w * (long) h * fps * bpp); + return (int) Math.max(2_000_000L, Math.min(bps, 80_000_000L)); + } + + private void stopScreenRecording() { + if (screenRecorder == null) return; + stopRecordUiCapture(); + if (xServerView != null) xServerView.setRenderMode(savedRenderMode); + VulkanRenderer renderer = xServerView != null ? xServerView.getRenderer() : null; + if (renderer != null) renderer.stopRecording(); + screenRecorder.stop(); + screenRecorder = null; + android.widget.Toast.makeText(this, R.string.session_record_saved, android.widget.Toast.LENGTH_SHORT).show(); + } + private void applyRefactorSize(boolean enabled) { if (winHandler == null || container == null) return; if (enabled) stageRefactorSizeHelper(); @@ -6496,16 +6903,18 @@ private void setupUI() { effectiveShowFPS = preferences.getBoolean("fps_monitor_enabled", false); - if (effectiveShowFPS) { - frameRating = new FrameRating(this, graphicsDriverConfig); - frameRating.setRenderer(lastRendererName); - if (lastGpuName != null) frameRating.setGpuName(lastGpuName); - frameRating.setVisibility(View.VISIBLE); - applyHUDSettings(); - updateHUDRenderMode(); - rootView.addView(frameRating); - if (perfController != null) perfController.attachToFrameRating(frameRating); - } + // Always create FrameRating so it feeds the phone gauge HUD; its on-screen overlay only shows + // when the FPS monitor is enabled. + frameRating = new FrameRating(this, graphicsDriverConfig); + frameRating.setRenderer(lastRendererName); + if (lastGpuName != null) frameRating.setGpuName(lastGpuName); + frameRating.setVisibility(effectiveShowFPS ? View.VISIBLE : View.GONE); + applyHUDSettings(); + updateHUDRenderMode(); + rootView.addView(frameRating); + if (perfController != null) perfController.attachToFrameRating(frameRating); + + setupControllerHudDetection(); startFullscreenStretched = "1".equals(getShortcutSetting("fullscreenStretched", container != null && container.isFullscreenStretched() ? "1" : "0")); @@ -6531,9 +6940,66 @@ private void setupUI() { startTouchscreenTimeout(); + // Detect a connected external display and offer to move the game onto it (controls stay here). + externalDisplayController = new ExternalDisplayController( + this, xServerDisplayFrame, xServerView, + new ExternalDisplayController.Callbacks() { + @Override + public void onExternalDisplayConnected(android.view.Display display) { + // Automatic swap: the game shows only on the external display, controls stay on the phone. + runOnUiThread(() -> { + if (isFinishing() || isDestroyed() || externalDisplayController == null + || externalDisplayController.isSwapActive()) return; + externalDisplayController.enterSwap(); + renderDrawerMenu(); + android.widget.Toast.makeText(XServerDisplayActivity.this, + R.string.display_output_swapped_toast, + android.widget.Toast.LENGTH_SHORT).show(); + }); + } + + @Override + public void onExternalDisplayDisconnected() { + runOnUiThread(() -> { + android.widget.Toast.makeText(XServerDisplayActivity.this, + R.string.display_output_restored_toast, + android.widget.Toast.LENGTH_SHORT).show(); + renderDrawerMenu(); + AppUtils.hideSystemUI(XServerDisplayActivity.this); + }); + } + + @Override + public void onSwapStateChanged(boolean swapActive) { + runOnUiThread(() -> { + // On return-to-phone, re-measure the display frame to reclaim full size. + if (!swapActive && drawerStateHolder != null) { + drawerStateHolder.requestPhoneRelayout(); + } + evaluateControllerHudMode(); + renderDrawerMenu(); + }); + } + }); + externalDisplayController.start(); + AppUtils.observeSoftKeyboardVisibility(displayHostComposeView, renderer::setScreenOffsetYRelativeToCursor); } + // Open the system Cast / wireless-display picker; a connected display flows through the swap path. + private void launchWirelessDisplayPicker() { + try { + startActivity(new Intent(android.provider.Settings.ACTION_CAST_SETTINGS)); + } catch (Exception e) { + try { + startActivity(new Intent(android.provider.Settings.ACTION_DISPLAY_SETTINGS)); + } catch (Exception ignore) { + android.widget.Toast.makeText(this, R.string.display_output_cast_unavailable, + android.widget.Toast.LENGTH_SHORT).show(); + } + } + } + private ActivityResultLauncher controlsEditorActivityResultLauncher = registerForActivityResult( @@ -6708,6 +7174,51 @@ private boolean hasActiveTouchscreenProfile() { return inputControlsView != null && inputControlsView.getProfile() != null; } + private void setupControllerHudDetection() { + android.hardware.input.InputManager im = + (android.hardware.input.InputManager) getSystemService(Context.INPUT_SERVICE); + if (im == null) return; + hudControllerListener = new android.hardware.input.InputManager.InputDeviceListener() { + @Override public void onInputDeviceAdded(int id) { evaluateControllerHudMode(); } + @Override public void onInputDeviceRemoved(int id) { evaluateControllerHudMode(); } + @Override public void onInputDeviceChanged(int id) { evaluateControllerHudMode(); } + }; + im.registerInputDeviceListener(hudControllerListener, + new android.os.Handler(android.os.Looper.getMainLooper())); + evaluateControllerHudMode(); + } + + private void evaluateControllerHudMode() { + boolean controller = + com.winlator.cmod.runtime.input.ControllerHelper.INSTANCE.isControllerConnected(); + boolean externalDisplay = + externalDisplayController != null && externalDisplayController.isSwapActive(); + updateControllerHudMode(controller && externalDisplay); + } + + // Physical controller present -> disable the touch controls and show the gauge HUD; otherwise + // restore the normal touch controls + on-screen overlay. The trackpad (touchpadView) stays either way. + private void updateControllerHudMode(boolean connected) { + if (connected == controllerHudMode) return; + controllerHudMode = connected; + runOnUiThread(() -> { + com.winlator.cmod.runtime.display.PerformanceHudState.setVisible(connected); + if (frameRating != null) frameRating.setHudMirrorActive(connected); + if (connected) { + if (inputControlsView != null) inputControlsView.setVisibility(View.GONE); + if (frameRating != null) frameRating.setVisibility(View.GONE); + // Lock onto the game window now so FPS/renderer come from it (it's on the external display). + syncFrameRatingWithExistingWindows(); + } else { + if (effectiveShowFPS && frameRating != null) frameRating.setVisibility(View.VISIBLE); + if (inputControlsView != null && hasActiveTouchscreenProfile() + && preferences.getBoolean("show_touchscreen_controls_enabled", false)) { + inputControlsView.setVisibility(View.VISIBLE); + } + } + }); + } + private void applyTouchscreenOverlayPreference() { if (inputControlsView == null || touchpadView == null) return; @@ -6905,7 +7416,7 @@ private void simulateConfirmInputControlsDialog() { private void startTouchscreenTimeout() { if (inputControlsView == null || touchpadView == null) return; touchpadView.setOnTouchListener(null); - if (inputControlsRevealAllowed && hasActiveTouchscreenProfile()) { + if (!controllerHudMode && inputControlsRevealAllowed && hasActiveTouchscreenProfile()) { inputControlsView.setVisibility(View.VISIBLE); } } @@ -6933,6 +7444,8 @@ private void showInputControls(ControlsProfile profile) { winHandler.sendGamepadState(); } startTouchscreenTimeout(); + // In controller-HUD mode the on-screen controls stay hidden even though the profile is set. + if (controllerHudMode) inputControlsView.setVisibility(View.GONE); } private void hideInputControls() { @@ -10253,7 +10766,7 @@ private void syncFrameRatingWithExistingWindows() { } private boolean shouldRecordFpsFrame(Window window, WindowManager.FrameSource source) { - if (!effectiveShowFPS || frameRating == null || window == null) return false; + if ((!effectiveShowFPS && !controllerHudMode) || frameRating == null || window == null) return false; if (source == WindowManager.FrameSource.UNKNOWN) return false; if (frameRatingWindowId == window.id) return true; if (isRelatedToFrameRatingWindow(window)) return true; diff --git a/app/src/main/runtime/display/XServerDisplayHost.kt b/app/src/main/runtime/display/XServerDisplayHost.kt index 9cf3a1611..cfe3c5e9d 100644 --- a/app/src/main/runtime/display/XServerDisplayHost.kt +++ b/app/src/main/runtime/display/XServerDisplayHost.kt @@ -9,13 +9,23 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -36,10 +46,12 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex +import com.winlator.cmod.R import com.winlator.cmod.shared.theme.WinNativeTheme import kotlinx.coroutines.launch import kotlin.math.abs @@ -160,6 +172,11 @@ private fun XServerDisplayHost( callbacks.onDialogVisibilityChanged(dialogVisible) } + // On swap-back, re-measure the hosted display frame so the reparented surface reclaims full size. + LaunchedEffect(stateHolder.phoneRelayoutTick) { + if (stateHolder.phoneRelayoutTick > 0) displayFrame.requestLayout() + } + WinNativeTheme { BoxWithConstraints( modifier = @@ -289,6 +306,35 @@ private fun XServerDisplayHost( ) } + // Performance HUD: half the screen (left in landscape, top in portrait), consuming its own + // touches so the rest stays a trackpad. Rendered in the host (not a nested ComposeView). + val perfHudVisible by PerformanceHudState.visible.collectAsState() + if (perfHudVisible) { + val landscape = + LocalConfiguration.current.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + Box( + modifier = + Modifier + .zIndex(1f) + .then( + if (landscape) { + Modifier.fillMaxHeight().fillMaxWidth(0.5f).align(Alignment.CenterStart) + } else { + Modifier.fillMaxWidth().fillMaxHeight(0.5f).align(Alignment.TopCenter) + }, + ) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { it.consume() } + } + } + }, + ) { + PerformanceHudOverlay() + } + } + ModalDrawerSheet( drawerShape = RoundedCornerShape(20.dp), drawerContainerColor = PaneSurfaceColor, diff --git a/app/src/main/runtime/display/XServerDrawerMenu.kt b/app/src/main/runtime/display/XServerDrawerMenu.kt index 1e1f8bb73..e72674e2d 100644 --- a/app/src/main/runtime/display/XServerDrawerMenu.kt +++ b/app/src/main/runtime/display/XServerDrawerMenu.kt @@ -73,6 +73,7 @@ import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.DeleteSweep import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FiberManualRecord import androidx.compose.material.icons.outlined.Fullscreen import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.Monitor @@ -91,6 +92,8 @@ import androidx.compose.material.icons.outlined.ZoomIn import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.LocalRippleConfiguration @@ -126,6 +129,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy @@ -192,6 +196,7 @@ private val DisabledCardBorder = Color(0xFF202033).copy(alpha = 0.58f) private val ActiveCardBorder = DrawerActiveAccent private val BottomDividerColor = WinNativeOutline private val GlassExitTint = Color(0xFFE07B6B) +private val RecordRed = Color(0xFFE53935) // Pane content scales down on short displays. private val LocalPaneScale = staticCompositionLocalOf { 1f } @@ -212,7 +217,7 @@ private enum class HUDMetricEditor( BACKGROUND_ALPHA(minPercent = 10, maxPercent = 100), } -internal enum class DrawerPane { INPUT_CONTROLS, HUD, GYROSCOPE, SCREEN_EFFECTS, TASK_MANAGER, LOGS, TOUCH } +internal enum class DrawerPane { INPUT_CONTROLS, HUD, GYROSCOPE, SCREEN_EFFECTS, OUTPUT, TASK_MANAGER, LOGS, TOUCH } internal const val LogsPaneMaxLines = 2000 @@ -275,6 +280,13 @@ private val RAIL_PANES = itemId = R.id.main_menu_screen_effects, labelRes = R.string.session_drawer_rail_label_effects, ), + // Shown only when the host adds a main_menu_output item to state.items. + RailPaneSpec( + pane = DrawerPane.OUTPUT, + itemId = R.id.main_menu_output, + labelRes = R.string.session_drawer_rail_label_output, + iconOverride = Icons.Outlined.Monitor, + ), ) private val RAIL_PANE_ITEM_IDS = RAIL_PANES.map { it.itemId }.toSet() @@ -302,6 +314,16 @@ data class XServerDrawerItem( val enabled: Boolean = true, ) +/** Device-filtered options + persisted selections for the Record popup. Quality: 0=Perf,1=Balance,2=Quality. */ +data class RecordUiConfig( + val fpsOptions: List = emptyList(), + val resolutionLabels: List = emptyList(), + val fpsIndex: Int = 0, + val resolutionIndex: Int = 0, + val quality: Int = 2, + val recordUI: Boolean = false, +) + data class XServerDrawerState( val items: List, val hudTransparency: Float = 1.0f, @@ -361,6 +383,37 @@ data class XServerDrawerState( val inputControlsGamepadVibration: Boolean = true, val inputControlsGcmRumbleMode: String = "disabled", val cursorSpeed: Float = 1.0f, + // External display / cast "Output" pane. + val outputSwapActive: Boolean = false, + val outputDisplayName: String = "", + val outputResolutionLabels: List = emptyList(), + val outputSelectedResolutionIndex: Int = 0, + val outputRefreshLabels: List = emptyList(), + val outputSelectedRefreshIndex: Int = 0, + val outputAspectMode: Int = 0, + val outputGameModeSupported: Boolean = false, + val outputGameModeEnabled: Boolean = false, + // Sink ignored real mode switches — phone is scaling; resolution becomes a render-size control. + val outputPanelScaling: Boolean = false, + val outputPanelNative: String = "", + // Display connected but game still on the phone — show the "Send to display" button. + val outputDisplayAvailable: Boolean = false, + // Viture XR glasses controls (USB), present only when Viture glasses are connected. + val outputVitureConnected: Boolean = false, + val outputVitureName: String = "", + val outputVitureSupportsBrightness: Boolean = false, + val outputVitureBrightness: Int = 0, + val outputVitureBrightnessMax: Int = 8, + val outputVitureSupportsFilm: Boolean = false, + val outputVitureFilmStepped: Boolean = false, + val outputVitureFilm: Int = 0, + val outputVitureSupports3D: Boolean = false, + val outputViture3D: Boolean = false, + val outputVitureSupportsVolume: Boolean = false, + val outputVitureVolume: Int = 0, + val outputVitureVolumeMax: Int = 8, + // Record settings popup config (device-aware options + persisted selections). + val recordConfig: RecordUiConfig = RecordUiConfig(), val mouseEnabled: Boolean = true, val relativeMouseEnabled: Boolean = false, val screenTouchMode: Int = 0, @@ -391,6 +444,14 @@ class XServerDrawerStateHolder( internal var openPane by mutableStateOf(null) private var paneVisibilityListener: ((Boolean) -> Unit)? = null + // Bumped on swap-back so the host re-requests a layout pass on the Compose-hosted display frame. + var phoneRelayoutTick by mutableStateOf(0) + private set + + fun requestPhoneRelayout() { + phoneRelayoutTick++ + } + val isDrawerOpen: Boolean get() = drawerOpen @@ -536,6 +597,28 @@ interface XServerDrawerActionListener { fun onScreenEffectsCardExpandedChanged(expanded: Boolean) + fun onOutputResolutionSelected(index: Int) + + fun onOutputRefreshRateSelected(index: Int) + + fun onOutputAspectModeSelected(mode: Int) + + fun onOutputGameModeToggled(enabled: Boolean) + + fun onOutputVitureBrightness(level: Int) + + fun onOutputVitureFilm(level: Int) + + fun onOutputViture3D(enabled: Boolean) + + fun onOutputVitureVolume(level: Int) + + fun onOutputReturnToPhone() + + fun onOutputSwapToDisplay() + + fun onOutputCastClick() + fun onSGSREnabledChanged(enabled: Boolean) fun onSGSRSharpnessChanged(sharpness: Int) @@ -627,6 +710,9 @@ interface XServerDrawerActionListener { fun onLogsPaneVisibilityChanged(visible: Boolean) fun onLogsShare() + + /** Start recording with the chosen settings (indices into the option lists in RecordUiConfig). */ + fun onRecordStart(fpsIndex: Int, resolutionIndex: Int, quality: Int, recordUI: Boolean) } fun buildXServerDrawerState( @@ -697,6 +783,8 @@ fun buildXServerDrawerState( fullscreenEnabled: Boolean = false, maxRefreshRate: Int = 60, refactorSizeEnabled: Boolean = false, + recordingActive: Boolean = false, + recordConfig: RecordUiConfig = RecordUiConfig(), screenTouchMode: Int = 0, rtsGesturesEnabled: Boolean = false, gestureProfileNames: List = emptyList(), @@ -802,6 +890,15 @@ fun buildXServerDrawerState( icon = Icons.AutoMirrored.Outlined.ViewList, ) + items += + XServerDrawerItem( + itemId = R.id.main_menu_record, + title = context.getString(R.string.session_drawer_rail_label_record), + subtitle = "", + icon = Icons.Outlined.FiberManualRecord, + active = recordingActive, + ) + if (showLogs) { items.add( XServerDrawerItem( @@ -822,6 +919,7 @@ fun buildXServerDrawerState( ) return XServerDrawerState( + recordConfig = recordConfig, items = items, hudTransparency = hudTransparency, hudBackgroundAlphaEnabled = hudBackgroundAlphaEnabled, @@ -916,6 +1014,78 @@ fun setupXServerDrawerComposeView( } } +// Append the always-present "Output" tab item and its state to the drawer state. +fun withOutputState( + state: XServerDrawerState, + swapActive: Boolean, + displayName: String, + resolutionLabels: List, + selectedResolutionIndex: Int, + refreshLabels: List, + selectedRefreshIndex: Int, + aspectMode: Int, + gameModeSupported: Boolean, + gameModeEnabled: Boolean, + panelScaling: Boolean, + panelNative: String, + displayAvailable: Boolean, +): XServerDrawerState { + val outputItem = + XServerDrawerItem( + itemId = R.id.main_menu_output, + title = "Output", + subtitle = "", + icon = Icons.Outlined.Monitor, + ) + return state.copy( + items = state.items + outputItem, + outputSwapActive = swapActive, + outputDisplayName = displayName, + outputResolutionLabels = resolutionLabels, + outputSelectedResolutionIndex = selectedResolutionIndex, + outputRefreshLabels = refreshLabels, + outputSelectedRefreshIndex = selectedRefreshIndex, + outputAspectMode = aspectMode, + outputGameModeSupported = gameModeSupported, + outputGameModeEnabled = gameModeEnabled, + outputPanelScaling = panelScaling, + outputPanelNative = panelNative, + outputDisplayAvailable = displayAvailable, + ) +} + +// Overlay Viture-glasses control state onto the output state (only when Viture glasses are connected). +fun withVitureState( + state: XServerDrawerState, + name: String, + supportsBrightness: Boolean, + brightness: Int, + brightnessMax: Int, + supportsFilm: Boolean, + filmStepped: Boolean, + film: Int, + supports3D: Boolean, + threeD: Boolean, + supportsVolume: Boolean, + volume: Int, + volumeMax: Int, +): XServerDrawerState = + state.copy( + outputVitureConnected = true, + outputVitureName = name, + outputVitureSupportsBrightness = supportsBrightness, + outputVitureBrightness = brightness, + outputVitureBrightnessMax = brightnessMax, + outputVitureSupportsFilm = supportsFilm, + outputVitureFilmStepped = filmStepped, + outputVitureFilm = film, + outputVitureSupports3D = supports3D, + outputViture3D = threeD, + outputVitureSupportsVolume = supportsVolume, + outputVitureVolume = volume, + outputVitureVolumeMax = volumeMax, + ) + @Composable internal fun XServerDrawerContent( state: XServerDrawerState, @@ -1010,6 +1180,7 @@ internal fun XServerDrawerContent( DrawerPane.GYROSCOPE -> GyroscopePaneContent(state = state, listener = listener) DrawerPane.TOUCH -> TouchPaneContent(state = state, listener = listener, onClose = { onOpenPaneChange(null) }) DrawerPane.SCREEN_EFFECTS -> ScreenEffectsPaneContent(state = state, listener = listener) + DrawerPane.OUTPUT -> OutputPaneContent(state = state, listener = listener) DrawerPane.TASK_MANAGER -> TaskManagerPaneContent( taskManagerState = taskManagerState, @@ -1279,6 +1450,7 @@ private fun ActionCardGrid( state.items.filter { it.itemId !in RAIL_PANE_ITEM_IDS && it.itemId !in PINNED_BOTTOM_ITEM_IDS } + var showRecordSettings by remember { mutableStateOf(false) } val verticalPadding = (10f * paneScale).dp BoxWithConstraints(modifier = Modifier.fillMaxSize()) { @@ -1314,7 +1486,13 @@ private fun ActionCardGrid( when (item.itemId) { R.id.main_menu_task_manager -> onOpenTaskManager() R.id.main_menu_logs -> onOpenLogs() + // Recording: stop. Otherwise open the settings popup. + R.id.main_menu_record -> + if (item.active) listener.onActionSelected(item.itemId) + else showRecordSettings = true R.id.main_menu_touch -> onOpenTouch() + R.id.main_menu_relative_mouse_movement, + R.id.main_menu_disable_mouse, R.id.main_menu_toggle_fullscreen -> listener.onActionSelected(item.itemId) else -> listener.onActionSelected(item.itemId) } @@ -1328,6 +1506,17 @@ private fun ActionCardGrid( } } } + + if (showRecordSettings) { + RecordSettingsDialog( + config = state.recordConfig, + onDismiss = { showRecordSettings = false }, + onRecordNow = { fpsIndex, resIndex, quality, recordUI -> + showRecordSettings = false + listener.onRecordStart(fpsIndex, resIndex, quality, recordUI) + }, + ) + } } @Composable @@ -1613,6 +1802,7 @@ private fun railLabelResFor(itemId: Int): Int? = R.id.main_menu_pip_mode -> R.string.session_drawer_rail_label_pip R.id.main_menu_magnifier -> R.string.session_drawer_rail_label_magnifier R.id.main_menu_task_manager -> R.string.session_drawer_rail_label_task_manager + R.id.main_menu_record -> R.string.session_drawer_rail_label_record R.id.main_menu_logs -> R.string.session_drawer_rail_label_logs else -> null } @@ -2759,6 +2949,340 @@ private fun ScreenEffectsPaneContent( } +@Composable +private fun OutputPaneContent( + state: XServerDrawerState, + listener: XServerDrawerActionListener, +) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val paneScale = computePaneScale(maxHeight) + CompositionLocalProvider(LocalPaneScale provides paneScale) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + if (state.outputSwapActive) { + OutputActiveControls(state = state, listener = listener, paneScale = paneScale) + } else if (state.outputDisplayAvailable) { + OutputSendToDisplay(state = state, listener = listener, paneScale = paneScale) + } else { + OutputCastEntry(listener = listener, paneScale = paneScale) + } + } + } + } +} + +@Composable +private fun OutputActiveControls( + state: XServerDrawerState, + listener: XServerDrawerActionListener, + paneScale: Float, +) { + OutputDeviceHeader(state = state, paneScale = paneScale) + + OutputCard(paneScale = paneScale, title = stringResource(R.string.session_drawer_output_display)) { + if (state.outputResolutionLabels.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + OutputFieldLabel(stringResource(R.string.session_drawer_output_resolution), paneScale) + InputControlsSimpleDropdown( + options = state.outputResolutionLabels, + selectedIndex = state.outputSelectedResolutionIndex, + onSelected = listener::onOutputResolutionSelected, + ) + Text( + text = if (state.outputPanelScaling) { + stringResource(R.string.session_drawer_output_scaling_note, state.outputPanelNative) + } else { + stringResource(R.string.session_drawer_output_render_note) + }, + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + lineHeight = (15f * paneScale).sp, + ) + } + } + if (!state.outputPanelScaling && state.outputRefreshLabels.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + OutputFieldLabel(stringResource(R.string.session_drawer_output_refresh_rate), paneScale) + InputControlsSimpleDropdown( + options = state.outputRefreshLabels, + selectedIndex = state.outputSelectedRefreshIndex, + onSelected = listener::onOutputRefreshRateSelected, + ) + } + } + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + OutputFieldLabel(stringResource(R.string.session_drawer_output_aspect_ratio), paneScale) + val aspectLabels = + listOf( + stringResource(R.string.session_drawer_output_aspect_fit), + stringResource(R.string.session_drawer_output_aspect_stretch), + stringResource(R.string.session_drawer_output_aspect_zoom), + ) + ChipFlow { + aspectLabels.forEachIndexed { index, label -> + HUDToggleChip( + label = label, + checked = state.outputAspectMode == index, + onClick = { listener.onOutputAspectModeSelected(index) }, + ) + } + } + } + } + + if (state.outputGameModeSupported) { + OutputCard(paneScale = paneScale, title = stringResource(R.string.session_drawer_output_game_mode)) { + ChipFlow { + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_on), + checked = state.outputGameModeEnabled, + onClick = { listener.onOutputGameModeToggled(true) }, + ) + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_off), + checked = !state.outputGameModeEnabled, + onClick = { listener.onOutputGameModeToggled(false) }, + ) + } + Text( + text = stringResource(R.string.session_drawer_output_game_mode_note), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + lineHeight = (15f * paneScale).sp, + ) + } + } + + if (state.outputVitureConnected) { + OutputGlassesCard(state = state, listener = listener, paneScale = paneScale) + } + + OutputPaneButton( + label = stringResource(R.string.session_drawer_output_return_to_phone), + paneScale = paneScale, + onClick = listener::onOutputReturnToPhone, + ) +} + +@Composable +private fun OutputGlassesCard( + state: XServerDrawerState, + listener: XServerDrawerActionListener, + paneScale: Float, +) { + OutputCard( + paneScale = paneScale, + title = state.outputVitureName.ifEmpty { stringResource(R.string.session_drawer_output_glasses) }, + ) { + if (state.outputVitureSupportsBrightness) { + DrawerSliderRow( + label = stringResource(R.string.session_drawer_output_brightness), + valueText = "${state.outputVitureBrightness}/${state.outputVitureBrightnessMax}", + value = state.outputVitureBrightness.toFloat(), + valueRange = 0f..state.outputVitureBrightnessMax.toFloat(), + steps = (state.outputVitureBrightnessMax - 1).coerceAtLeast(0), + onValueChange = { listener.onOutputVitureBrightness(it.roundToInt()) }, + ) + } + if (state.outputVitureSupportsFilm) { + if (state.outputVitureFilmStepped) { + DrawerSliderRow( + label = stringResource(R.string.session_drawer_output_shade), + valueText = "${state.outputVitureFilm}/8", + value = state.outputVitureFilm.toFloat(), + valueRange = 0f..8f, + steps = 7, + onValueChange = { listener.onOutputVitureFilm(it.roundToInt()) }, + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + OutputFieldLabel(stringResource(R.string.session_drawer_output_shade), paneScale) + ChipFlow { + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_on), + checked = state.outputVitureFilm > 0, + onClick = { listener.onOutputVitureFilm(1) }, + ) + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_off), + checked = state.outputVitureFilm == 0, + onClick = { listener.onOutputVitureFilm(0) }, + ) + } + } + } + } + if (state.outputVitureSupportsVolume) { + DrawerSliderRow( + label = stringResource(R.string.session_drawer_output_volume), + valueText = "${state.outputVitureVolume}/${state.outputVitureVolumeMax}", + value = state.outputVitureVolume.toFloat(), + valueRange = 0f..state.outputVitureVolumeMax.toFloat(), + steps = (state.outputVitureVolumeMax - 1).coerceAtLeast(0), + onValueChange = { listener.onOutputVitureVolume(it.roundToInt()) }, + ) + } + if (state.outputVitureSupports3D) { + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + OutputFieldLabel(stringResource(R.string.session_drawer_output_3d), paneScale) + ChipFlow { + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_on), + checked = state.outputViture3D, + onClick = { listener.onOutputViture3D(true) }, + ) + HUDToggleChip( + label = stringResource(R.string.session_drawer_output_game_mode_off), + checked = !state.outputViture3D, + onClick = { listener.onOutputViture3D(false) }, + ) + } + } + } + Text( + text = stringResource(R.string.session_drawer_output_glasses_note), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + lineHeight = (15f * paneScale).sp, + ) + } +} + +@Composable +private fun OutputCard( + paneScale: Float, + title: String, + content: @Composable () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape((14f * paneScale).dp)) + .background(PaneInnerResting) + .border(1.dp, RestingCardBorder, RoundedCornerShape((14f * paneScale).dp)) + .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((10f * paneScale).dp), + ) { + PaneSectionLabel(title) + content() + } +} + +@Composable +private fun OutputFieldLabel(text: String, paneScale: Float) { + Text( + text = text, + color = DrawerTextSecondary, + fontSize = (12f * paneScale).sp, + fontWeight = FontWeight.Medium, + ) +} + +@Composable +private fun OutputDeviceHeader(state: XServerDrawerState, paneScale: Float) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + modifier = Modifier.padding(horizontal = (2f * paneScale).dp), + ) { + Icon( + imageVector = Icons.Outlined.Monitor, + contentDescription = null, + tint = DrawerAccent, + modifier = Modifier.size((22f * paneScale).dp), + ) + Text( + text = state.outputDisplayName.ifEmpty { stringResource(R.string.session_drawer_output_title) }, + color = DrawerTextPrimary, + fontSize = (15f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun OutputSendToDisplay( + state: XServerDrawerState, + listener: XServerDrawerActionListener, + paneScale: Float, +) { + OutputDeviceHeader(state = state, paneScale = paneScale) + OutputPaneButton( + label = stringResource(R.string.session_drawer_output_send_to_display), + paneScale = paneScale, + onClick = listener::onOutputSwapToDisplay, + ) + Text( + text = stringResource(R.string.session_drawer_output_send_note), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + lineHeight = (15f * paneScale).sp, + ) +} + +@Composable +private fun OutputCastEntry( + listener: XServerDrawerActionListener, + paneScale: Float, +) { + Column(verticalArrangement = Arrangement.spacedBy((6f * paneScale).dp)) { + PaneSectionLabel(stringResource(R.string.session_drawer_output_cast_title)) + Text( + text = stringResource(R.string.session_drawer_output_cast_body), + color = DrawerTextSecondary, + fontSize = (12f * paneScale).sp, + lineHeight = (16f * paneScale).sp, + ) + } + OutputPaneButton( + label = stringResource(R.string.session_drawer_output_cast_button), + paneScale = paneScale, + onClick = listener::onOutputCastClick, + ) + Text( + text = stringResource(R.string.session_drawer_output_cast_note), + color = DrawerTextSecondary, + fontSize = (11f * paneScale).sp, + lineHeight = (15f * paneScale).sp, + ) +} + +@Composable +private fun OutputPaneButton( + label: String, + paneScale: Float, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape((14f * paneScale).dp)) + .background(PaneInnerResting) + .border(1.dp, RestingCardBorder, RoundedCornerShape((14f * paneScale).dp)) + .clickable { onClick() } + .padding(horizontal = (12f * paneScale).dp, vertical = (12f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = label, + color = DrawerTextPrimary, + fontSize = (14f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + ) + } +} + @Composable private fun TaskManagerPaneContent( taskManagerState: TaskManagerPaneState, @@ -4707,6 +5231,135 @@ private fun DrawerBooleanRow( } } +private val RECORD_QUALITY_LABELS = listOf("Performance", "Balance", "Quality") + +/** Centered popup for choosing recording fps / resolution / quality (+ Record UI), then Record Now. */ +@Composable +private fun RecordSettingsDialog( + config: RecordUiConfig, + onDismiss: () -> Unit, + onRecordNow: (fpsIndex: Int, resolutionIndex: Int, quality: Int, recordUI: Boolean) -> Unit, +) { + val fpsOptions = config.fpsOptions.ifEmpty { listOf(60) } + val resOptions = config.resolutionLabels.ifEmpty { listOf("Native") } + + var fpsIndex by remember { mutableStateOf(config.fpsIndex.coerceIn(0, fpsOptions.lastIndex)) } + var resIndex by remember { mutableStateOf(config.resolutionIndex.coerceIn(0, resOptions.lastIndex)) } + var quality by remember { mutableStateOf(config.quality.coerceIn(0, RECORD_QUALITY_LABELS.lastIndex)) } + var recordUI by remember { mutableStateOf(config.recordUI) } + + val shape = RoundedCornerShape(16.dp) + // Cap card height (landscape is short); settings scroll, the Record Now button stays pinned. + val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + Box( + modifier = Modifier.fillMaxSize().safeDrawingPadding().padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = + Modifier + .widthIn(max = 360.dp) + .fillMaxWidth() + .heightIn(max = maxCardHeight) + .clip(shape) + .background(PaneSurfaceColor) + .border(1.dp, RestingCardBorder, shape) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.FiberManualRecord, + contentDescription = null, + tint = RecordRed, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(R.string.session_record_settings_title), + color = DrawerTextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + } + + // Scrollable settings above the pinned button. + Column( + modifier = Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + DrawerSliderRow( + label = stringResource(R.string.session_record_fps), + valueText = "${fpsOptions[fpsIndex]} fps", + value = fpsIndex.toFloat(), + valueRange = 0f..(fpsOptions.lastIndex.coerceAtLeast(1)).toFloat(), + steps = (fpsOptions.size - 2).coerceAtLeast(0), + onValueChange = { if (fpsOptions.size > 1) fpsIndex = it.roundToInt().coerceIn(0, fpsOptions.lastIndex) }, + ) + + DrawerSliderRow( + label = stringResource(R.string.session_record_resolution), + valueText = resOptions[resIndex], + value = resIndex.toFloat(), + valueRange = 0f..(resOptions.lastIndex.coerceAtLeast(1)).toFloat(), + steps = (resOptions.size - 2).coerceAtLeast(0), + onValueChange = { if (resOptions.size > 1) resIndex = it.roundToInt().coerceIn(0, resOptions.lastIndex) }, + ) + + DrawerSliderRow( + label = stringResource(R.string.session_record_quality), + valueText = RECORD_QUALITY_LABELS[quality], + value = quality.toFloat(), + valueRange = 0f..(RECORD_QUALITY_LABELS.lastIndex).toFloat(), + steps = (RECORD_QUALITY_LABELS.size - 2).coerceAtLeast(0), + onValueChange = { quality = it.roundToInt().coerceIn(0, RECORD_QUALITY_LABELS.lastIndex) }, + ) + + DrawerBooleanRow( + title = stringResource(R.string.session_record_include_ui), + checked = recordUI, + onCheckedChange = { recordUI = it }, + subtitle = stringResource(R.string.session_record_include_ui_subtitle), + ) + } + + // Record Now button (pinned). + Button( + onClick = { onRecordNow(fpsIndex, resIndex, quality, recordUI) }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = RecordRed, + contentColor = Color.White, + ), + ) { + Icon( + imageVector = Icons.Outlined.FiberManualRecord, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.session_record_now), + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } + } +} + private const val FPS_LIMITER_MIN = 30 private const val FPS_LIMITER_DEFAULT = 60 diff --git a/app/src/main/runtime/display/recording/GameRecorder.java b/app/src/main/runtime/display/recording/GameRecorder.java new file mode 100644 index 000000000..028de945c --- /dev/null +++ b/app/src/main/runtime/display/recording/GameRecorder.java @@ -0,0 +1,510 @@ +package com.winlator.cmod.runtime.display.recording; + +import android.content.Context; +import android.media.AudioFormat; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.media.MediaMuxer; +import android.media.MediaScannerConnection; +import android.os.Build; +import android.os.Environment; +import android.util.Log; +import android.view.Surface; + +import java.io.File; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Records the game's composited output to an MP4 (H.264 video + AAC audio). Video frames come from + * the native Vulkan renderer (mirror-presented into {@link #getInputSurface()}); game audio is teed + * in via {@link #onPcm}. A background thread drains both encoders into the muxer. + */ +public final class GameRecorder { + private static final String TAG = "GameRecorder"; + + private static final String VIDEO_MIME = MediaFormat.MIMETYPE_VIDEO_AVC; + private static final String AUDIO_MIME = MediaFormat.MIMETYPE_AUDIO_AAC; + private static final int AUDIO_BITRATE = 128_000; + private static final long DEQUEUE_TIMEOUT_US = 10_000; + + // The active recorder the audio bridge tees PCM into (one recording session at a time). + private static volatile GameRecorder activeRecorder; + + public static GameRecorder active() { + return activeRecorder; + } + + private final Context appContext; + + // Video + private MediaCodec videoCodec; + private Surface inputSurface; + private int videoTrackIndex = -1; + + // Audio (lazily configured from the first PCM buffer so we match the game's real format) + private MediaCodec audioCodec; + private int audioTrackIndex = -1; + private int audioSampleRate; + private int audioChannelCount; + private long audioFramesSubmitted; // advances the audio sample clock + private long audioBasePtsUs = -1; // wall-clock anchor of the first PCM, shared with video base + + private MediaMuxer muxer; + private boolean muxerStarted; + private File outputFile; + private int orientationHint; // clockwise degrees players rotate playback to upright + + private Thread drainThread; + private final AtomicBoolean recording = new AtomicBoolean(false); + private final AtomicBoolean stopRequested = new AtomicBoolean(false); + private long baseTimeNs; + // If no game audio has appeared by this deadline, start the muxer video-only rather than stall. + private long audioGraceDeadlineNs; + private static final long AUDIO_GRACE_NS = 1_000_000_000L; + + // Samples that arrive before both tracks are added + muxer is started. + private final List pending = new ArrayList<>(); + + private static final class PendingSample { + final boolean video; + final ByteBuffer data; + final MediaCodec.BufferInfo info; + + PendingSample(boolean video, ByteBuffer data, MediaCodec.BufferInfo info) { + this.video = video; + this.data = data; + this.info = info; + } + } + + public GameRecorder(Context context) { + this.appContext = context.getApplicationContext(); + } + + public boolean isRecording() { + return recording.get(); + } + + /** Configure the encoder + muxer and start draining. Returns the input Surface, or null on failure. */ + public synchronized Surface start(int width, int height, int fps, int orientationHint, int bitRate) { + if (recording.get()) return inputSurface; + width &= ~1; // encoders want even dimensions + height &= ~1; + if (width <= 0 || height <= 0) { + Log.e(TAG, "Refusing to record invalid size " + width + "x" + height); + return null; + } + if (fps <= 0 || fps > 240) fps = 60; + if (bitRate <= 0) bitRate = estimateVideoBitrate(width, height, fps); + this.orientationHint = ((orientationHint % 360) + 360) % 360; + + try { + if (!openOutput()) { + abortOutput(); + return null; + } + + MediaFormat fmt = MediaFormat.createVideoFormat(VIDEO_MIME, width, height); + fmt.setInteger(MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + fmt.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); + fmt.setInteger(MediaFormat.KEY_FRAME_RATE, fps); + fmt.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + fmt.setInteger(MediaFormat.KEY_BITRATE_MODE, + MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR); + } + + videoCodec = MediaCodec.createEncoderByType(VIDEO_MIME); + videoCodec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + inputSurface = videoCodec.createInputSurface(); + videoCodec.start(); + } catch (Exception e) { + Log.e(TAG, "Failed to start video encoder", e); + releaseQuietly(); + abortOutput(); + return null; + } + + baseTimeNs = System.nanoTime(); + audioGraceDeadlineNs = baseTimeNs + AUDIO_GRACE_NS; + stopRequested.set(false); + recording.set(true); + activeRecorder = this; + + drainThread = new Thread(this::drainLoop, "GameRecorderDrain"); + drainThread.start(); + Log.i(TAG, "Recording started " + width + "x" + height + "@" + fps); + return inputSurface; + } + + public Surface getInputSurface() { + return inputSurface; + } + + /** Tee a buffer of already-played game PCM into the recording (non-blocking; does not consume it). */ + public void onPcm(ByteBuffer data, int sampleRate, int channelCount, int pcmEncoding) { + if (!recording.get() || stopRequested.get()) return; + try { + ensureAudioEncoder(sampleRate, channelCount); + } catch (Exception e) { + Log.e(TAG, "Audio encoder init failed; continuing video-only", e); + audioCodec = null; // give up on audio, keep recording video + return; + } + MediaCodec codec = audioCodec; + if (codec == null) return; + + ByteBuffer pcm16 = toPcm16(data, pcmEncoding); + if (pcm16 == null || pcm16.remaining() == 0) return; + + // Anchor the audio clock to the same base as the video Surface timestamps, then advance by samples. + if (audioBasePtsUs < 0) { + audioBasePtsUs = Math.max(0L, (System.nanoTime() - baseTimeNs) / 1000L); + } + try { + while (pcm16.hasRemaining()) { + int inIndex = codec.dequeueInputBuffer(0); + if (inIndex < 0) break; // no input buffer free right now — drop the rest this tick + ByteBuffer in = codec.getInputBuffer(inIndex); + if (in == null) break; + in.clear(); + int chunk = Math.min(in.remaining(), pcm16.remaining()); + int oldLimit = pcm16.limit(); + pcm16.limit(pcm16.position() + chunk); + in.put(pcm16); + pcm16.limit(oldLimit); + + long ptsUs = audioBasePtsUs + + audioFramesSubmitted * 1_000_000L / Math.max(1, audioSampleRate); + int bytesPerFrame = audioChannelCount * 2; + audioFramesSubmitted += chunk / Math.max(1, bytesPerFrame); + codec.queueInputBuffer(inIndex, 0, chunk, ptsUs, 0); + } + } catch (Exception e) { + Log.e(TAG, "queue audio failed", e); + } + } + + public synchronized void stop() { + if (!recording.get()) return; + stopRequested.set(true); + try { + if (videoCodec != null) videoCodec.signalEndOfInputStream(); + } catch (Exception ignore) {} + if (audioCodec != null) { + try { + int inIndex = audioCodec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US); + if (inIndex >= 0) { + long base = audioBasePtsUs < 0 ? 0L : audioBasePtsUs; + long ptsUs = base + + audioFramesSubmitted * 1_000_000L / Math.max(1, audioSampleRate); + audioCodec.queueInputBuffer(inIndex, 0, 0, ptsUs, + MediaCodec.BUFFER_FLAG_END_OF_STREAM); + } + } catch (Exception ignore) {} + } + + // Wait for the drain thread to exit before finishOutput() releases the muxer. + Thread t = drainThread; + if (t != null) { + try { + t.join(4_000); + } catch (InterruptedException ignore) { + Thread.currentThread().interrupt(); + } + } + recording.set(false); + if (activeRecorder == this) activeRecorder = null; + finishOutput(); + releaseQuietly(); + Log.i(TAG, "Recording stopped"); + } + + // ── Draining ───────────────────────────────────────────────────────────── + + private void drainLoop() { + MediaCodec.BufferInfo videoInfo = new MediaCodec.BufferInfo(); + MediaCodec.BufferInfo audioInfo = new MediaCodec.BufferInfo(); + boolean videoDone = false; + boolean audioDone = false; + + long stopDeadlineNs = 0; + while (!videoDone) { + videoDone = drainEncoder(videoCodec, videoInfo, true); + if (audioCodec != null && !audioDone) { // appears partway through (lazy init) + audioDone = drainEncoder(audioCodec, audioInfo, false); + } + synchronized (this) { maybeStartMuxer(); } + if (stopRequested.get()) { + // Bound the wait for EOS so stop() can finalize without nulling the muxer mid-drain. + if (stopDeadlineNs == 0) stopDeadlineNs = System.nanoTime() + 1_500_000_000L; + else if (System.nanoTime() > stopDeadlineNs) break; + } else if (!videoDone) { + try { Thread.sleep(2); } catch (InterruptedException e) { break; } + } + } + // After video EOS, flush any remaining audio so trailing sound isn't truncated. + if (audioCodec != null) { + long deadline = System.nanoTime() + 500_000_000L; + while (!audioDone && System.nanoTime() < deadline) { + audioDone = drainEncoder(audioCodec, audioInfo, false); + } + } + } + + /** Returns true when this encoder has emitted end-of-stream. */ + private boolean drainEncoder(MediaCodec codec, MediaCodec.BufferInfo info, boolean video) { + if (codec == null) return true; + try { + int index = codec.dequeueOutputBuffer(info, DEQUEUE_TIMEOUT_US); + if (index == MediaCodec.INFO_TRY_AGAIN_LATER) { + return false; + } else if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + MediaFormat newFormat = codec.getOutputFormat(); + synchronized (this) { + // Tracks can only be added before the muxer starts; a late stream is dropped. + if (!muxerStarted && muxer != null) { + if (video) videoTrackIndex = muxer.addTrack(newFormat); + else audioTrackIndex = muxer.addTrack(newFormat); + maybeStartMuxer(); + } + } + return false; + } else if (index >= 0) { + ByteBuffer out = codec.getOutputBuffer(index); + if ((info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + info.size = 0; // config already consumed by addTrack + } + if (video && info.size > 0) { + // Rebase Surface timestamps to the recording start (shared origin with audio). + info.presentationTimeUs = Math.max(0L, + info.presentationTimeUs - baseTimeNs / 1000L); + } + if (out != null && info.size > 0) { + out.position(info.offset); + out.limit(info.offset + info.size); + writeSample(video, out, info); + } + boolean eos = (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + codec.releaseOutputBuffer(index, false); + return eos; + } + } catch (IllegalStateException e) { + // Codec released underneath us during stop(). + return true; + } + return false; + } + + private synchronized void writeSample(boolean video, ByteBuffer data, + MediaCodec.BufferInfo info) { + int track = video ? videoTrackIndex : audioTrackIndex; + if (muxerStarted) { + if (track < 0) return; // stream not in the muxer (e.g. late audio) — drop, don't buffer + try { + muxer.writeSampleData(track, data, info); + } catch (Exception e) { + Log.e(TAG, "writeSampleData failed", e); + } + return; + } + // Muxer not started yet: hold a copy until both tracks are added. + ByteBuffer copy = ByteBuffer.allocate(info.size); + copy.put(data); + copy.flip(); + MediaCodec.BufferInfo ci = new MediaCodec.BufferInfo(); + ci.set(0, info.size, info.presentationTimeUs, info.flags); + pending.add(new PendingSample(video, copy, ci)); + } + + /** Start the muxer once the video track is known; include audio if it has appeared. */ + private void maybeStartMuxer() { + if (muxerStarted || muxer == null) return; + if (videoTrackIndex < 0) return; + // If audio exists, wait for its track; if none has appeared, give it a grace window first. + boolean audioTrackPending = audioCodec != null && audioTrackIndex < 0; + boolean audioMightStillAppear = audioCodec == null && System.nanoTime() < audioGraceDeadlineNs; + if (audioTrackPending || audioMightStillAppear) return; + try { + muxer.start(); + muxerStarted = true; + for (PendingSample s : pending) { + int track = s.video ? videoTrackIndex : audioTrackIndex; + if (track >= 0) muxer.writeSampleData(track, s.data, s.info); + } + pending.clear(); + } catch (Exception e) { + Log.e(TAG, "muxer.start failed", e); + } + } + + // ── Audio helpers ──────────────────────────────────────────────────────── + + private synchronized void ensureAudioEncoder(int sampleRate, int channelCount) throws Exception { + if (audioCodec != null || sampleRate <= 0 || channelCount <= 0) return; + channelCount = Math.min(channelCount, 2); // AAC-LC: encode stereo at most + MediaFormat fmt = MediaFormat.createAudioFormat(AUDIO_MIME, sampleRate, channelCount); + fmt.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); + fmt.setInteger(MediaFormat.KEY_BIT_RATE, AUDIO_BITRATE); + fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16 * 1024); + MediaCodec codec = MediaCodec.createEncoderByType(AUDIO_MIME); + codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + codec.start(); + audioSampleRate = sampleRate; + audioChannelCount = channelCount; + audioCodec = codec; + } + + /** Convert source PCM to interleaved 16-bit little-endian (the AAC encoder's input format). */ + private static ByteBuffer toPcm16(ByteBuffer src, int pcmEncoding) { + int pos = src.position(); + try { + if (pcmEncoding == AudioFormat.ENCODING_PCM_16BIT) { + if (src.order() == ByteOrder.LITTLE_ENDIAN) { + ByteBuffer out = ByteBuffer.allocate(src.remaining()); + out.put(src.duplicate()); + out.flip(); + return out.order(ByteOrder.LITTLE_ENDIAN); + } + // Big-endian source: read shorts in source order, write little-endian. + ByteBuffer s = src.duplicate(); + s.order(src.order()); + int shorts = s.remaining() / 2; + ByteBuffer out = ByteBuffer.allocate(shorts * 2).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < shorts; i++) out.putShort(s.getShort()); + out.flip(); + return out; + } else if (pcmEncoding == AudioFormat.ENCODING_PCM_FLOAT) { + ByteBuffer s = src.duplicate(); + s.order(src.order()); + int floats = s.remaining() / 4; + ByteBuffer out = ByteBuffer.allocate(floats * 2).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < floats; i++) { + float f = s.getFloat(); + int v = Math.round(f * 32767f); + if (v > 32767) v = 32767; else if (v < -32768) v = -32768; + out.putShort((short) v); + } + out.flip(); + return out; + } else if (pcmEncoding == AudioFormat.ENCODING_PCM_8BIT) { + ByteBuffer s = src.duplicate(); + int n = s.remaining(); + ByteBuffer out = ByteBuffer.allocate(n * 2).order(ByteOrder.LITTLE_ENDIAN); + for (int i = 0; i < n; i++) { + int u = s.get() & 0xFF; // unsigned 8-bit + out.putShort((short) ((u - 128) << 8)); + } + out.flip(); + return out; + } + return null; + } finally { + src.position(pos); + } + } + + private static int estimateVideoBitrate(int width, int height, int fps) { + // ~0.07 bits per pixel·frame, clamped to a sane window for shareable clips. + long bps = (long) (width * (long) height * fps * 0.07); + return (int) Math.max(4_000_000L, Math.min(bps, 50_000_000L)); + } + + // ── Output (WinNative/Recordings in the app's external files dir) ──────── + + /** /sdcard/WinNative/Recordings (alongside logs/profiles/saves; needs MANAGE_EXTERNAL_STORAGE). */ + private File recordingsDir() { + File ext = Environment.getExternalStorageDirectory(); + if (ext != null) return new File(ext, "WinNative/Recordings"); + File base = appContext.getExternalFilesDir(null); + if (base == null) base = appContext.getFilesDir(); + return new File(base, "Recordings"); + } + + private boolean openOutput() { + String name = "WinNative_" + System.currentTimeMillis() + ".mp4"; + try { + File dir = recordingsDir(); + if (!dir.isDirectory() && !dir.mkdirs() && !dir.isDirectory()) { + Log.e(TAG, "Could not create " + dir); + return false; + } + outputFile = new File(dir, name); + muxer = new MediaMuxer(outputFile.getAbsolutePath(), + MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); + // Must be set before the muxer starts; rotates playback to upright on any player. + if (orientationHint != 0) muxer.setOrientationHint(orientationHint); + return true; + } catch (Exception e) { + Log.e(TAG, "openOutput failed", e); + return false; + } + } + + private void finishOutput() { + if (muxer != null) { + try { + if (muxerStarted) muxer.stop(); + } catch (Exception ignore) {} + try { + muxer.release(); + } catch (Exception ignore) {} + muxer = null; + } + muxerStarted = false; + // Make the new file visible to media scanners / file managers right away. + if (outputFile != null && outputFile.length() > 0) { + try { + MediaScannerConnection.scanFile(appContext, + new String[]{outputFile.getAbsolutePath()}, new String[]{"video/mp4"}, null); + } catch (Exception ignore) {} + } + outputFile = null; + } + + /** Release output without finalizing, and delete the empty file — used when start() fails. */ + private void abortOutput() { + if (muxer != null) { + try { muxer.release(); } catch (Exception ignore) {} + muxer = null; + } + muxerStarted = false; + try { + if (outputFile != null) //noinspection ResultOfMethodCallIgnored + outputFile.delete(); + } catch (Exception ignore) {} + outputFile = null; + } + + private void releaseQuietly() { + try { + if (videoCodec != null) videoCodec.stop(); + } catch (Exception ignore) {} + try { + if (videoCodec != null) videoCodec.release(); + } catch (Exception ignore) {} + videoCodec = null; + try { + if (inputSurface != null) inputSurface.release(); + } catch (Exception ignore) {} + inputSurface = null; + try { + if (audioCodec != null) audioCodec.stop(); + } catch (Exception ignore) {} + try { + if (audioCodec != null) audioCodec.release(); + } catch (Exception ignore) {} + audioCodec = null; + drainThread = null; + videoTrackIndex = -1; + audioTrackIndex = -1; + audioFramesSubmitted = 0; + audioBasePtsUs = -1; + } +} diff --git a/app/src/main/runtime/display/renderer/ViewTransformation.java b/app/src/main/runtime/display/renderer/ViewTransformation.java index 4a83faf4c..e8144e3a9 100644 --- a/app/src/main/runtime/display/renderer/ViewTransformation.java +++ b/app/src/main/runtime/display/renderer/ViewTransformation.java @@ -1,6 +1,15 @@ package com.winlator.cmod.runtime.display.renderer; public class ViewTransformation { + /** Letterbox: preserve the game's aspect ratio, centering it with black bars (default). */ + public static final int FILL_MODE_FIT = 0; + /** Stretch the game to fill the whole surface, ignoring aspect ratio. */ + public static final int FILL_MODE_STRETCH = 1; + /** Zoom/crop: fill the surface preserving aspect, cropping the overflowing edges. */ + public static final int FILL_MODE_ZOOM = 2; + + public int mode = FILL_MODE_FIT; + public int viewOffsetX; public int viewOffsetY; public int viewWidth; @@ -12,7 +21,26 @@ public class ViewTransformation { public float sceneOffsetY; public void update(int outerWidth, int outerHeight, int innerWidth, int innerHeight) { - aspect = Math.min((float) outerWidth / innerWidth, (float) outerHeight / innerHeight); + if (outerWidth <= 0 || outerHeight <= 0 || innerWidth <= 0 || innerHeight <= 0) return; + + if (mode == FILL_MODE_STRETCH) { + // Game scene fills the entire surface; no centering, no aspect preservation. + aspect = (float) outerWidth / innerWidth; + viewOffsetX = 0; + viewOffsetY = 0; + viewWidth = outerWidth; + viewHeight = outerHeight; + sceneScaleX = 1f; + sceneScaleY = 1f; + sceneOffsetX = 0f; + sceneOffsetY = 0f; + return; + } + + // FIT uses the smaller scale (letterbox); ZOOM uses the larger scale (fill + crop). + aspect = (mode == FILL_MODE_ZOOM) + ? Math.max((float) outerWidth / innerWidth, (float) outerHeight / innerHeight) + : Math.min((float) outerWidth / innerWidth, (float) outerHeight / innerHeight); viewWidth = (int) Math.ceil(innerWidth * aspect); viewHeight = (int) Math.ceil(innerHeight * aspect); viewOffsetX = (int) ((outerWidth - innerWidth * aspect) * 0.5f); diff --git a/app/src/main/runtime/display/renderer/VulkanRenderer.java b/app/src/main/runtime/display/renderer/VulkanRenderer.java index eb0008a7e..d7e692b85 100644 --- a/app/src/main/runtime/display/renderer/VulkanRenderer.java +++ b/app/src/main/runtime/display/renderer/VulkanRenderer.java @@ -83,8 +83,10 @@ public void setSwapRB(boolean v) { private float magnifierPanY = 0f; private boolean magnifierPanInitialized = false; private static final float MAGNIFIER_DEADZONE_FRACTION = 0.6f; - public int surfaceWidth; - public int surfaceHeight; + // volatile: written from the main thread (onXServerScreenChanged / fill-mode / display-swap + // recompute) and read from the render thread (buildAndSubmitFrame self-heal). + public volatile int surfaceWidth; + public volatile int surfaceHeight; private boolean cpuSaverMode = false; private static final long CURSOR_ACTIVE_NS = 100_000_000L; private volatile long cursorActiveUntilNs = 0L; @@ -193,27 +195,30 @@ public void setGraphicsDriver(String driverName) { } public void attachSurface(Surface surface) { - if (nativeHandle == 0) { - nativeHandle = nativeCreate(shouldEnableValidationLayers(), - graphicsDriverName, xServerView.getContext().getApplicationContext()); + // Serialize with detachSurface()/destroy() so a re-attach can't overlap a native teardown. + synchronized (this) { if (nativeHandle == 0) { - Log.e(TAG, "nativeCreate failed"); - return; - } - Texture.setRendererHandle(nativeHandle); - // Apply the cached present-mode request now that the native renderer exists. - // No-op if the requested mode equals the native default (FIFO). - if (requestedPresentMode != PRESENT_MODE_FIFO) { - nativeSetPresentMode(nativeHandle, requestedPresentMode); - } - if (requestedScaleFilter != SCALE_FILTER_OFF) { - nativeSetScaleFilter(nativeHandle, requestedScaleFilter); + nativeHandle = nativeCreate(shouldEnableValidationLayers(), + graphicsDriverName, xServerView.getContext().getApplicationContext()); + if (nativeHandle == 0) { + Log.e(TAG, "nativeCreate failed"); + return; + } + Texture.setRendererHandle(nativeHandle); + // Apply the cached present-mode request now that the native renderer exists. + // No-op if the requested mode equals the native default (FIFO). + if (requestedPresentMode != PRESENT_MODE_FIFO) { + nativeSetPresentMode(nativeHandle, requestedPresentMode); + } + if (requestedScaleFilter != SCALE_FILTER_OFF) { + nativeSetScaleFilter(nativeHandle, requestedScaleFilter); + } + destroyed.set(false); + xServer.windowManager.addOnWindowModificationListener(this); + xServer.pointer.addOnPointerMotionListener(this); } - destroyed.set(false); - xServer.windowManager.addOnWindowModificationListener(this); - xServer.pointer.addOnPointerMotionListener(this); + nativeSurfaceCreated(nativeHandle, surface); } - nativeSurfaceCreated(nativeHandle, surface); } private boolean shouldEnableValidationLayers() { @@ -233,7 +238,52 @@ public void notifySurfaceChanged(int w, int h) { } public void detachSurface() { - if (nativeHandle != 0) nativeSurfaceDestroyed(nativeHandle); + // Same monitor as destroy()/attachSurface; re-check the handle under the lock. + synchronized (this) { + if (nativeHandle != 0) nativeSurfaceDestroyed(nativeHandle); + } + } + + /** Start mirroring the composited output into {@code encoderSurface}; false if the native setup failed. */ + public boolean startRecording(Surface encoderSurface, int fps, boolean recordUI) { + synchronized (this) { + if (nativeHandle == 0 || encoderSurface == null) return false; + return nativeStartRecording(nativeHandle, encoderSurface, fps, recordUI); + } + } + + /** Upload the latest overlay snapshot (direct ByteBuffer of BGRA pixels) for the Record-UI composite. */ + public void updateRecordUITexture(java.nio.ByteBuffer bgra, int width, int height) { + long handle = nativeHandle; + if (handle != 0 && bgra != null && bgra.isDirect()) { + nativeUpdateRecordUITexture(handle, bgra, width, height); + } + } + + public void stopRecording() { + synchronized (this) { + if (nativeHandle != 0) nativeStopRecording(nativeHandle); + } + } + + /** Width of the actual composited image (may differ from the SurfaceView size under rotation). */ + public int getRecordWidth() { + synchronized (this) { + return nativeHandle != 0 ? nativeGetRecordWidth(nativeHandle) : 0; + } + } + + public int getRecordHeight() { + synchronized (this) { + return nativeHandle != 0 ? nativeGetRecordHeight(nativeHandle) : 0; + } + } + + /** Clockwise degrees to rotate captured frames to appear upright (undoes the display rotation). */ + public int getRecordOrientationHint() { + synchronized (this) { + return nativeHandle != 0 ? nativeGetRecordOrientationHint(nativeHandle) : 0; + } } @Override @@ -264,6 +314,20 @@ public void onDrawFrame() { // ----- Scene assembly ---------------------------------------------------- private void buildAndSubmitFrame() { + // Self-heal: if the actual surface size differs from our cache (after a display reparent), + // recompute the viewport against the real size. + if (xServerView != null) { + int actualW = xServerView.getSurfaceWidth(); + int actualH = xServerView.getSurfaceHeight(); + if (actualW > 0 && actualH > 0 && (actualW != surfaceWidth || actualH != surfaceHeight)) { + surfaceWidth = actualW; + surfaceHeight = actualH; + viewTransformation.update(actualW, actualH, + xServer.screenInfo.width, xServer.screenInfo.height); + viewportNeedsUpdate = true; + } + } + // Compute scene transform / viewport / scissor (mirrors GLRenderer.drawFrame logic). textureUploadBatch.reset(); boolean useScissor = false; @@ -307,13 +371,21 @@ private void buildAndSubmitFrame() { buf.putInt(OFF_VIEWPORT + 8, viewW); buf.putInt(OFF_VIEWPORT + 12, viewH); - // Scissor (only in non-magnifier non-fullscreen mode) + // Scissor (only in non-magnifier non-fullscreen mode). Clamp to the framebuffer so a + // ZOOM/crop fill mode (whose viewport intentionally overflows the surface) never asks + // Vulkan for an out-of-bounds scissor. For FIT/STRETCH this is a no-op. if (useScissor) { + int sX = Math.max(0, viewTransformation.viewOffsetX); + int sY = Math.max(0, viewTransformation.viewOffsetY); + int sRight = Math.min(surfaceWidth, viewTransformation.viewOffsetX + viewTransformation.viewWidth); + int sBottom = Math.min(surfaceHeight, viewTransformation.viewOffsetY + viewTransformation.viewHeight); + int sW = Math.max(0, sRight - sX); + int sH = Math.max(0, sBottom - sY); buf.putInt(OFF_SCISSOR_ENABLED, 1); - buf.putInt(OFF_SCISSOR, viewTransformation.viewOffsetX); - buf.putInt(OFF_SCISSOR + 4, viewTransformation.viewOffsetY); - buf.putInt(OFF_SCISSOR + 8, viewTransformation.viewWidth); - buf.putInt(OFF_SCISSOR + 12, viewTransformation.viewHeight); + buf.putInt(OFF_SCISSOR, sX); + buf.putInt(OFF_SCISSOR + 4, sY); + buf.putInt(OFF_SCISSOR + 8, sW); + buf.putInt(OFF_SCISSOR + 12, sH); } else { buf.putInt(OFF_SCISSOR_ENABLED, 0); // Native side gates on scissor_enabled regardless, but zero the rect for cleanliness. @@ -734,6 +806,45 @@ private void computeMagnifierPan(float[] outXForm) { public boolean isViewportNeedsUpdate() { return viewportNeedsUpdate; } public void setViewportNeedsUpdate(boolean v) { this.viewportNeedsUpdate = v; } + // Fill mode (FIT/STRETCH/ZOOM), applied live: recompute the viewport and request a frame. + public void setFillMode(int mode) { + if (viewTransformation.mode == mode) return; + viewTransformation.mode = mode; + if (surfaceWidth > 0 && surfaceHeight > 0) { + viewTransformation.update(surfaceWidth, surfaceHeight, + xServer.screenInfo.width, xServer.screenInfo.height); + } + viewportNeedsUpdate = true; + if (xServerView != null) xServerView.requestRender(); + } + + public int getFillMode() { return viewTransformation.mode; } + + // Set the fill mode without recomputing the viewport (cached size may be stale mid-reparent). + public void setFillModeQuiet(int mode) { + viewTransformation.mode = mode; + viewportNeedsUpdate = true; + } + + public int getPresentMode() { return requestedPresentMode; } + + // Wipe the cached surface size so the next surfaceChanged/self-heal recomputes from scratch. + public void invalidateSurfaceSize() { + surfaceWidth = 0; + surfaceHeight = 0; + viewportNeedsUpdate = true; + } + + /** Force the viewport to recompute against a known surface size (used after a display reparent). */ + public void forceViewportRecompute(int w, int h) { + if (w <= 0 || h <= 0) return; + surfaceWidth = w; + surfaceHeight = h; + viewTransformation.update(w, h, xServer.screenInfo.width, xServer.screenInfo.height); + viewportNeedsUpdate = true; + if (xServerView != null) xServerView.requestRender(); + } + public void setNativeMode(boolean enable) { if (cpuSaverMode != enable) { cpuSaverMode = enable; @@ -767,9 +878,7 @@ public void setFpsLimit(int fps) { public static final int PRESENT_MODE_MAILBOX = 1; public static final int PRESENT_MODE_IMMEDIATE = 2; - // Cached so callers can set a mode before the native renderer exists. Applied during - // attachSurface() right after nativeCreate. Updates after init forward straight to the - // native side and trigger a swapchain rebuild. + // Cached so a mode can be set before the native renderer exists (applied in attachSurface). private int requestedPresentMode = PRESENT_MODE_FIFO; public void setPresentMode(int mode) { @@ -820,6 +929,12 @@ private static native long nativeCreate(boolean enableValidationLayers, private static native void nativeSurfaceCreated(long handle, Surface surface); private static native void nativeSurfaceChanged(long handle, int w, int h); private static native void nativeSurfaceDestroyed(long handle); + private static native boolean nativeStartRecording(long handle, Surface encoderSurface, int fps, boolean recordUI); + private static native void nativeStopRecording(long handle); + private static native void nativeUpdateRecordUITexture(long handle, java.nio.ByteBuffer bgra, int width, int height); + private static native int nativeGetRecordWidth(long handle); + private static native int nativeGetRecordHeight(long handle); + private static native int nativeGetRecordOrientationHint(long handle); private static native boolean nativeRenderFrame(long handle); private static native void nativeSetScene(long handle, ByteBuffer sceneBuf); private static native void nativeSetFpsLimit(long handle, int fps); diff --git a/app/src/main/runtime/display/ui/FrameRating.java b/app/src/main/runtime/display/ui/FrameRating.java index 58471d5e6..dab98740b 100644 --- a/app/src/main/runtime/display/ui/FrameRating.java +++ b/app/src/main/runtime/display/ui/FrameRating.java @@ -131,6 +131,9 @@ public void setFrameObserver(FrameObserver observer) { private boolean isNativeActive; private boolean isStatsRunning; private volatile boolean isCharging; + // When the controller HUD mirrors our values to the Compose overlay, the on-screen view is hidden + // (GONE) but we must still compute FPS/frametime so the mirrored gauges stay live. + private volatile boolean hudMirrorActive; private volatile float lastFPS; private volatile long lastFrameNano; private long lastPrimaryFrameNano; @@ -1151,6 +1154,10 @@ private void updateSeparators(boolean horizontal) { } /** Called when the guest submits a new frame to the X presentation path. */ + public void setHudMirrorActive(boolean active) { + this.hudMirrorActive = active; + } + public void recordGameFrame(boolean primarySource, int serial) { // Notify observer before any visibility gating so perf recording / leaderboard stats keep // working when the HUD is hidden. Cheap path; observer is typically a single AtomicLong @@ -1159,7 +1166,7 @@ public void recordGameFrame(boolean primarySource, int serial) { if (obs != null) { obs.onFramePresent(System.nanoTime()); } - if (getVisibility() != View.VISIBLE) { + if (getVisibility() != View.VISIBLE && !this.hudMirrorActive) { return; } long nowNano = System.nanoTime(); @@ -1405,11 +1412,18 @@ private void calculateStats() { } } + private int ramPercentValue() { + try { + return Integer.parseInt(this.ramText.replace("%", "").trim()); + } catch (Exception e) { + return -1; + } + } + @Override public void run() { - if (getVisibility() != View.VISIBLE) return; - - // Watchdog: reset FPS if no frames arrived for > 1.5s + // Watchdog first so a stalled game drops to 0 on the mirrored HUD too: reset FPS if no frames + // arrived for > 1.5s, then publish the (possibly reset) values. long nowNano = System.nanoTime(); if (this.lastFrameNano > 0 && nowNano - this.lastFrameNano > 1500000000L) { synchronized (this) { @@ -1419,6 +1433,12 @@ public void run() { this.frameTimesCount = 0; } } + // Feed the phone gauge HUD (single source of truth) even while the on-screen overlay is hidden. + com.winlator.cmod.runtime.display.PerformanceHudState.updateValues( + this.lastFPS, this.currentMs, this.gpuLoad, this.cpuPercent, ramPercentValue(), + (this.dualSeriesBattery && this.batteryWatts >= 0.0f) ? this.batteryWatts * 2.0f : this.batteryWatts, + this.cpuTemp, this.rendererName != null ? this.rendererName : ""); + if (getVisibility() != View.VISIBLE) return; if (this.enableGpu && this.tvGpuLoad != null) { SpannableStringBuilder b = new SpannableStringBuilder(); diff --git a/app/src/main/runtime/display/ui/XServerSurfaceView.java b/app/src/main/runtime/display/ui/XServerSurfaceView.java index 60e39bfcf..c1e00a40d 100644 --- a/app/src/main/runtime/display/ui/XServerSurfaceView.java +++ b/app/src/main/runtime/display/ui/XServerSurfaceView.java @@ -31,6 +31,9 @@ public class XServerSurfaceView extends SurfaceView implements SurfaceHolder.Cal private final Object renderLock = new Object(); private final Deque eventQueue = new ArrayDeque<>(); private Thread renderThread; + // Outgoing render thread still finishing teardown; the next surfaceCreated joins it before + // re-attaching so a stale destroy() can't free the handle the new surface re-attaches to. + private Thread retiringRenderThread; private volatile boolean running; private volatile boolean renderRequested; private volatile boolean transientRenderRequested; @@ -114,6 +117,8 @@ public void onPause() { @Override public void surfaceCreated(SurfaceHolder holder) { + // Let any retiring render thread finish freeing the renderer before attaching the new surface. + joinRetiringRenderThread(); synchronized (renderLock) { surfaceReady = false; width = 0; @@ -123,6 +128,17 @@ public void surfaceCreated(SurfaceHolder holder) { startRenderThreadIfNeeded(); } + private void joinRetiringRenderThread() { + Thread t; + synchronized (renderLock) { + t = retiringRenderThread; + retiringRenderThread = null; + } + if (t != null && t != Thread.currentThread() && t.isAlive()) { + try { t.join(3000); } catch (InterruptedException ignore) {} + } + } + @Override public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) { if (w <= 0 || h <= 0) { @@ -172,6 +188,7 @@ private void stopRenderThread() { synchronized (renderLock) { running = false; renderLock.notifyAll(); + if (renderThread != null) retiringRenderThread = renderThread; renderThread = null; } }