From 04ae3fea5b599ae251b44932bd1bf13ef3115a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 22 Oct 2026 21:56:55 +0200 Subject: [PATCH] External display setting: Show the game, or mirror the screen Graphics > "External display" on iOS and Android. "Show the game" (the default) is the TV mode, "Mirror the screen" leaves the TV to the system. A change applies right away. It's disabled on backends that can't draw to a second swapchain. On Android 12 and up, a second screen that's part of the device (like a dual-screen handheld's) isn't used, so the game doesn't move there by itself. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/System/System.h | 1 + Core/Config.cpp | 1 + Core/Config.h | 1 + Core/ConfigValues.h | 6 ++++ UI/GameSettingsScreen.cpp | 15 +++++++++ android/jni/app-android.cpp | 7 +++-- .../src/org/ppsspp/ppsspp/PpssppActivity.java | 31 +++++++++++++++---- docs/multi-swapchain.md | 11 ++++--- ios/ExternalDisplaySceneDelegate.h | 2 ++ ios/ExternalDisplaySceneDelegate.mm | 19 +++++++++--- ios/main.mm | 6 ++++ 11 files changed, 84 insertions(+), 16 deletions(-) diff --git a/Common/System/System.h b/Common/System/System.h index b0fcb59a05..99a5911682 100644 --- a/Common/System/System.h +++ b/Common/System/System.h @@ -280,6 +280,7 @@ enum class SystemNotification { CONFIG_LOADED, BEFORE_CONFIG_SAVE_ON_EXIT, TOP_LEVEL_CHANGED, // NativeIsAtTopLevel() changed. From the render thread. + EXTERNAL_DISPLAY_CHANGED, // g_Config.iExternalDisplay changed. }; // I guess it's not super great architecturally to centralize this, since it's not general - but same with a lot of diff --git a/Core/Config.cpp b/Core/Config.cpp index 69b502402d..edcb81421d 100644 --- a/Core/Config.cpp +++ b/Core/Config.cpp @@ -749,6 +749,7 @@ static int DefaultAnalogAltSpeedPercent() { static constexpr ConfigSetting graphicsSettings[] = { ConfigSetting("iShowStatusFlags", SETTING(g_Config, iShowStatusFlags), 0, CfgFlag::PER_GAME), ConfigSetting("GraphicsBackend", SETTING(g_Config, iGPUBackend), &DefaultGPUBackend, &GPUBackendTranslator::To, &GPUBackendTranslator::From, CfgFlag::DEFAULT | CfgFlag::REPORT), + ConfigSetting("ExternalDisplay", SETTING(g_Config, iExternalDisplay), (int)ExternalDisplayMode::SHOW_GAME, CfgFlag::DEFAULT), #if PPSSPP_PLATFORM(ANDROID) && PPSSPP_ARCH(ARM64) ConfigSetting("CustomDriver", SETTING(g_Config, sCustomDriver), "", CfgFlag::DEFAULT), #endif diff --git a/Core/Config.h b/Core/Config.h index 5c065dd8b7..569c9fd0e1 100644 --- a/Core/Config.h +++ b/Core/Config.h @@ -288,6 +288,7 @@ public: // GFX int iGPUBackend; + int iExternalDisplay; // ExternalDisplayMode std::string sCustomDriver; std::string sFailedGPUBackends; // NOT stored in ppsspp.ini anymore! std::string sDisabledGPUBackends; diff --git a/Core/ConfigValues.h b/Core/ConfigValues.h index dd88588ae6..f06afd1f4c 100644 --- a/Core/ConfigValues.h +++ b/Core/ConfigValues.h @@ -135,6 +135,12 @@ enum class GPUBackend { METAL = 4, }; +// What an external display (a TV) shows, where the platform lets us choose. See docs/multi-swapchain.md. +enum class ExternalDisplayMode { + SHOW_GAME = 0, // The game, with the controls and the menus on the device. Needs Metal or Vulkan. + MIRROR = 1, +}; + enum class DepthRasterMode { DEFAULT = 0, LOW_QUALITY = 1, diff --git a/UI/GameSettingsScreen.cpp b/UI/GameSettingsScreen.cpp index e74a0c7707..9617feba82 100644 --- a/UI/GameSettingsScreen.cpp +++ b/UI/GameSettingsScreen.cpp @@ -328,6 +328,21 @@ void GameSettingsScreen::CreateGraphicsSettings(UI::ViewGroup *graphicsSettings) } #endif +#if (PPSSPP_PLATFORM(IOS) && !PPSSPP_PLATFORM(TVOS)) || PPSSPP_PLATFORM(ANDROID) + // Showing the game there needs a second swapchain. Otherwise the system mirrors the screen. + static const char *externalDisplayModes[] = { "Show the game", "Mirror the screen" }; + PopupMultiChoice *externalDisplayChoice = graphicsSettings->Add(new PopupMultiChoice(&g_Config.iExternalDisplay, gr->T("External display"), externalDisplayModes, 0, ARRAY_SIZE(externalDisplayModes), I18NCat::GRAPHICS, screenManager())); + externalDisplayChoice->SetEnabled(draw->GetDeviceCaps().multipleSwapchains); + // Picking the current value again would rebuild the TV's output for nothing (iOS starts over on a change). + externalDisplayChoice->OnChoice.Add([previous = g_Config.iExternalDisplay](UI::EventParams &e) mutable { + if (g_Config.iExternalDisplay == previous) { + return; + } + previous = g_Config.iExternalDisplay; + System_Notify(SystemNotification::EXTERNAL_DISPLAY_CHANGED); + }); +#endif + // Backends that don't allow a device choice will only expose one device. if (draw->GetDeviceList().size() > 1) { std::string *deviceNameSetting = GPUDeviceNameSetting(); diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp index 2ef41d44be..93c708ded5 100644 --- a/android/jni/app-android.cpp +++ b/android/jni/app-android.cpp @@ -593,8 +593,8 @@ static std::string QueryConfig(std::string_view query) { } else if (query == "audioMixWithOthers") { return g_Config.bAudioMixWithOthers ? "1" : "0"; } else if (query == "externalDisplayOutput") { - // Whether the game can go to a second display, see ExternalDisplayPresentation.java. - return g_Config.iGPUBackend == (int)GPUBackend::VULKAN ? "1" : "0"; + // Whether the game goes to a second display, see ExternalDisplayPresentation.java. + return g_Config.iGPUBackend == (int)GPUBackend::VULKAN && g_Config.iExternalDisplay == (int)ExternalDisplayMode::SHOW_GAME ? "1" : "0"; } else { return ""; } @@ -1032,6 +1032,9 @@ void System_Notify(SystemNotification notification) { case SystemNotification::SUSTAINED_PERF_CHANGE: PushCommand("sustainedPerfMode", ""); break; + case SystemNotification::EXTERNAL_DISPLAY_CHANGED: + PushCommand("externalDisplay", ""); + break; case SystemNotification::TEST_JAVA_EXCEPTION: PushCommand("testException", "This is a test exception"); break; diff --git a/android/src/org/ppsspp/ppsspp/PpssppActivity.java b/android/src/org/ppsspp/ppsspp/PpssppActivity.java index 7a92bd96d1..4b65c04746 100644 --- a/android/src/org/ppsspp/ppsspp/PpssppActivity.java +++ b/android/src/org/ppsspp/ppsspp/PpssppActivity.java @@ -24,6 +24,7 @@ import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; import android.hardware.SensorManager; +import android.hardware.display.DeviceProductInfo; import android.hardware.display.DisplayManager; import android.hardware.input.InputManager; import android.media.AudioManager; @@ -121,6 +122,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList // The game on a second display, while there's one (and the backend can draw to it) and we're started. private ExternalDisplayPresentation externalDisplayPresentation; + private boolean externalDisplayWatched = false; private final DisplayManager.DisplayListener displayListener = new DisplayManager.DisplayListener() { @Override public void onDisplayAdded(int displayId) { @@ -966,15 +968,17 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList updateSustainedPerformanceMode(); } - // Shows the game on a presentation display (like a TV on HDMI), if there's one and the backend can draw - // to a second surface (Vulkan). Otherwise Android mirrors the phone there. + // Shows the game on a presentation display (like a TV on HDMI), if there's one, the backend can draw + // to a second surface (Vulkan) and the setting says so. Otherwise Android mirrors the phone there. private void updateExternalDisplay() { Display display = null; - if ("1".equals(NativeApp.queryConfig("externalDisplayOutput"))) { + if (externalDisplayWatched && "1".equals(NativeApp.queryConfig("externalDisplayOutput"))) { DisplayManager displayManager = (DisplayManager)getSystemService(Context.DISPLAY_SERVICE); - Display[] displays = displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION); - if (displays.length > 0) { - display = displays[0]; + for (Display candidate : displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION)) { + if (!isBuiltInDisplay(candidate)) { + display = candidate; + break; + } } } if (externalDisplayPresentation != null && (display == null || externalDisplayPresentation.getDisplay().getDisplayId() != display.getDisplayId())) { @@ -993,6 +997,16 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } } + // A second screen that's part of the device (a dual-screen handheld's, a cover screen) isn't a TV, and + // the game shouldn't move there by itself. Only Android 12 and up can tell. + private static boolean isBuiltInDisplay(Display display) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return false; + } + DeviceProductInfo info = display.getDeviceProductInfo(); + return info != null && info.getConnectionToSinkType() == DeviceProductInfo.CONNECTION_TO_SINK_BUILT_IN; + } + private void dismissExternalDisplay() { if (externalDisplayPresentation != null) { externalDisplayPresentation.dismiss(); @@ -1142,6 +1156,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } DisplayManager displayManager = (DisplayManager)getSystemService(Context.DISPLAY_SERVICE); displayManager.registerDisplayListener(displayListener, null); + externalDisplayWatched = true; updateExternalDisplay(); } @@ -1164,6 +1179,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList // Before the render thread stops, so it can let go of the second display's surface. DisplayManager displayManager = (DisplayManager)getSystemService(Context.DISPLAY_SERVICE); displayManager.unregisterDisplayListener(displayListener); + externalDisplayWatched = false; dismissExternalDisplay(); // Normally surfaceDestroyed has done this already, but not every device destroys the surface of a @@ -1876,6 +1892,9 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList } else if (command.equals("sustainedPerfMode")) { updateSustainedPerformanceMode(); return true; + } else if (command.equals("externalDisplay")) { + updateExternalDisplay(); + return true; } else if (command.equals("immersive")) { updateSystemUiVisibility(); return true; diff --git a/docs/multi-swapchain.md b/docs/multi-swapchain.md index e44fe37473..6eb43c1ca9 100644 --- a/docs/multi-swapchain.md +++ b/docs/multi-swapchain.md @@ -269,8 +269,9 @@ can be tested on its own. the main thread waits for that (the same handshake as `requestExitVulkanRenderLoop`). Only then does it remove the window. Removing the window should give the TV back to system mirroring. **Verify on the device:** if iOS doesn't resume mirroring, we need a `Mirror` output for the menus instead. -- **Settings:** "When a TV is connected: Show the game on the TV (default) / Mirror the screen". The - pause menu gets a toggle for the current session. +- **Setting:** Graphics → "External display": "Show the game" (the default) or "Mirror the screen" + (`iExternalDisplay`, iOS and Android). It's disabled on backends without `multipleSwapchains`, and a + change applies right away (`SystemNotification::EXTERNAL_DISPLAY_CHANGED`). - **Out of scope:** iPad Stage Manager's interactive extended displays (role `Application`), and tvOS. - **AirPlay latency:** this is the same video stream as mirroring, so the same lag. Input stays local, @@ -283,8 +284,10 @@ can be tested on its own. (Miracast, like Samsung's Smart View), and Developer options → "Simulate secondary displays". Casting to a Chromecast or an Android TV is mirroring only. - **Showing it:** while it's started, the activity watches `DisplayManager` and, when there's a - `DISPLAY_CATEGORY_PRESENTATION` display and the backend is Vulkan (`queryConfig("externalDisplayOutput")`), - shows an `ExternalDisplayPresentation` there. That's a `Presentation` with a `SurfaceView`, whose + `DISPLAY_CATEGORY_PRESENTATION` display, the backend is Vulkan and the setting says "Show the game" + (`queryConfig("externalDisplayOutput")`), shows an `ExternalDisplayPresentation` there. Displays that + are part of the device (a dual-screen handheld's second screen) are skipped, where Android can tell + (12 and up, `DeviceProductInfo.CONNECTION_TO_SINK_BUILT_IN`). That's a `Presentation` with a `SurfaceView`, whose surface callbacks add, resize and remove a `Game` output through JNI. Otherwise Android mirrors. A backend change recreates the activity, which checks again. - **Removing:** the surface mustn't be touched once `surfaceDestroyed` returns, so it removes the output diff --git a/ios/ExternalDisplaySceneDelegate.h b/ios/ExternalDisplaySceneDelegate.h index 68470462cb..43253d98a2 100644 --- a/ios/ExternalDisplaySceneDelegate.h +++ b/ios/ExternalDisplaySceneDelegate.h @@ -30,3 +30,5 @@ // Called by the app's SceneDelegate when PPSSPP has (re)started, and the config is loaded. void ExternalDisplayNotifyLaunched(); +// g_Config.iExternalDisplay changed. On the main thread. +void ExternalDisplaySettingChanged(); diff --git a/ios/ExternalDisplaySceneDelegate.mm b/ios/ExternalDisplaySceneDelegate.mm index e9f07313b7..9d78203c8f 100644 --- a/ios/ExternalDisplaySceneDelegate.mm +++ b/ios/ExternalDisplaySceneDelegate.mm @@ -25,7 +25,7 @@ #include "Core/Config.h" #include "Core/ConfigValues.h" -static NSString *const PPSSPPDidLaunchNotification = @"PPSSPPDidLaunch"; +static NSString *const PPSSPPExternalDisplayUpdateNotification = @"PPSSPPExternalDisplayUpdate"; // Set by the SceneDelegate. Before that, the config (and so the graphics backend) isn't known. static bool g_launched = false; @@ -95,14 +95,15 @@ static bool g_launched = false; } self.windowScene = (UIWindowScene *)scene; INFO_LOG(Log::G3D, "External display connected"); - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didLaunch:) name:PPSSPPDidLaunchNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(update:) name:PPSSPPExternalDisplayUpdateNotification object:nil]; if (g_launched) { [self attach]; } } -- (void)didLaunch:(NSNotification *)notification { +- (void)update:(NSNotification *)notification { // After a restart, the outputs are gone along with the old screen manager, and the backend may have changed. + // Or the setting did. [self detach]; [self attach]; } @@ -116,6 +117,10 @@ static bool g_launched = false; INFO_LOG(Log::G3D, "External display: mirroring, as the backend can't present to it"); return; } + if (g_Config.iExternalDisplay != (int)ExternalDisplayMode::SHOW_GAME) { + INFO_LOG(Log::G3D, "External display: mirroring, as set"); + return; + } #if !TARGET_OS_TV for (UIScreenMode *mode in self.windowScene.screen.availableModes) { INFO_LOG(Log::G3D, "External display mode: %dx%d%s", (int)mode.size.width, (int)mode.size.height, mode == self.windowScene.screen.currentMode ? " (current)" : ""); @@ -149,5 +154,11 @@ static bool g_launched = false; void ExternalDisplayNotifyLaunched() { g_launched = true; - [[NSNotificationCenter defaultCenter] postNotificationName:PPSSPPDidLaunchNotification object:nil]; + [[NSNotificationCenter defaultCenter] postNotificationName:PPSSPPExternalDisplayUpdateNotification object:nil]; +} + +void ExternalDisplaySettingChanged() { + if (g_launched) { + [[NSNotificationCenter defaultCenter] postNotificationName:PPSSPPExternalDisplayUpdateNotification object:nil]; + } } diff --git a/ios/main.mm b/ios/main.mm index 60613c3b69..28ecf5affe 100644 --- a/ios/main.mm +++ b/ios/main.mm @@ -22,6 +22,7 @@ #import "AppDelegate.h" #import "ViewController.h" +#import "ExternalDisplaySceneDelegate.h" #import "iOSCoreAudio.h" #import "IAPManager.h" #import "SceneDelegate.h" @@ -444,6 +445,11 @@ void System_Notify(SystemNotification notification) { } }); break; + case SystemNotification::EXTERNAL_DISPLAY_CHANGED: + dispatch_async(dispatch_get_main_queue(), ^{ + ExternalDisplaySettingChanged(); + }); + break; case SystemNotification::IMMERSIVE_MODE_CHANGE: dispatch_async(dispatch_get_main_queue(), ^{ if (sharedViewController) {