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) <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-10-22 22:31:07 +02:00
1 parent 5fbd134a7d
commit 04ae3fea5b
11 files changed
+84 -16

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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;
+6
View File
@@ -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,
+15
View File
@@ -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();
+5 -2
View File
@@ -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;
@@ -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;
+7 -4
View File
@@ -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
+2
View File
@@ -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();
+15 -4
View File
@@ -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];
}
}
+6
View File
@@ -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) {