diff --git a/CMakeLists.txt b/CMakeLists.txt index c7421bd0bb..45b4971a69 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -960,6 +960,8 @@ elseif(IOS AND NOT LIBRETRO) ios/AppDelegate.h ios/SceneDelegate.mm ios/SceneDelegate.h + ios/ExternalDisplaySceneDelegate.mm + ios/ExternalDisplaySceneDelegate.h ios/Controls.h ios/Controls.mm ios/ViewController.h @@ -1003,6 +1005,7 @@ elseif(IOS AND NOT LIBRETRO) # TODO: Enable arc globally? set_source_files_properties(ios/AppDelegate.mm ios/SceneDelegate.mm + ios/ExternalDisplaySceneDelegate.mm ios/ViewController.mm ios/iOSCoreAudio.mm ios/IAPManager.mm diff --git a/UI/EmuScreen.cpp b/UI/EmuScreen.cpp index 875907f4cc..bde2586881 100644 --- a/UI/EmuScreen.cpp +++ b/UI/EmuScreen.cpp @@ -1577,6 +1577,16 @@ ScreenRenderRole EmuScreen::renderRole(bool isTop) const { return role; } +// In place of the game, while it's on another output. +void EmuScreen::drawGameOnOutputMessage() { + UIContext &dc = *screenManager()->getUIContext(); + auto di = GetI18NCategory(I18NCat::DIALOG); + const Bounds &bounds = dc.GetBounds(); + dc.Begin(); + dc.DrawText(di->T("Playing on TV"), bounds.centerX(), bounds.centerY(), 0xFF505050, ALIGN_CENTER); + dc.Flush(); +} + void EmuScreen::darken() { if (screenManager()->topScreen()->wantBrightBackground()) { return; @@ -1714,7 +1724,9 @@ ScreenRenderFlags EmuScreen::render(ScreenRenderMode mode) { Viewport viewport{0.0f, 0.0f, (float)g_display.pixel_xres, (float)g_display.pixel_yres, 0.0f, 1.0f}; if (!skipBufferEffects_ && !ShouldRunEmulation(mode)) { - if (gpu && !gameOnOutput_) { + if (gameOnOutput_) { + drawGameOnOutputMessage(); + } else if (gpu) { gpu->CopyDisplayToOutput(displayLayoutConfig); } draw->SetViewport(viewport); @@ -1747,7 +1759,9 @@ ScreenRenderFlags EmuScreen::render(ScreenRenderMode mode) { const bool skipBufferEffects = skipBufferEffects_; // Gotta copy the output at some point. Also this is where we take the screenshot if needed. - if (gpu && !gameOnOutput_) { + if (gameOnOutput_) { + drawGameOnOutputMessage(); + } else if (gpu) { gpu->CopyDisplayToOutput(displayLayoutConfig); } diff --git a/UI/EmuScreen.h b/UI/EmuScreen.h index 51065f308d..86b1a25368 100644 --- a/UI/EmuScreen.h +++ b/UI/EmuScreen.h @@ -65,6 +65,7 @@ public: protected: void darken(); + void drawGameOnOutputMessage(); void focusChanged(ScreenFocusChange focusChange) override; ScreenRenderFlags PreRender(ScreenRenderMode mode) override; bool renderOutput(const OutputDisplay &output) override; diff --git a/docs/multi-swapchain.md b/docs/multi-swapchain.md index 28d575f64e..62ac96a013 100644 --- a/docs/multi-swapchain.md +++ b/docs/multi-swapchain.md @@ -257,8 +257,11 @@ can be tested on its own. ## iOS -- **Info.plist:** add a `UIWindowSceneSessionRoleExternalDisplayNonInteractive` configuration with an - `ExternalDisplaySceneDelegate`. Without it, iOS just mirrors (today's behaviour). +- **Getting the scene:** up to iOS 26, a `UIWindowSceneSessionRoleExternalDisplayNonInteractive` + configuration in Info.plist, with `ExternalDisplaySceneDelegate`. From iOS 27, that's ignored, and the + app has to register from a view controller instead (`registerSceneAccessory:` with + `UISceneAccessory externalNonInteractiveSceneAccessoryWithConfiguration:`). We do both. Without a + scene, iOS just mirrors. An AirPlay Apple TV 4K comes in at 3840x2160. - **Connect:** the delegate keeps the scene, and posts "external display available" (size from `screen.nativeBounds`, refresh rate, overscan) to the native side. It doesn't create a window yet. - **TV mode on:** when it should be active (setting on, `EmuScreen` running), the main thread creates diff --git a/ios/AppDelegate.mm b/ios/AppDelegate.mm index be35453073..a29f1246f4 100644 --- a/ios/AppDelegate.mm +++ b/ios/AppDelegate.mm @@ -1,5 +1,6 @@ #import "AppDelegate.h" #import "SceneDelegate.h" +#import "ExternalDisplaySceneDelegate.h" #import "iOSCoreAudio.h" #import "Common/System/System.h" #import "Common/System/NativeApp.h" @@ -23,6 +24,7 @@ // This is necessary because otherwise classes from static libraries aren't loaded. // We should move all the iOS code into the main binary directly to avoid this problem. __attribute__((used)) static Class _forceLinkSceneDelegate = [SceneDelegate class]; +__attribute__((used)) static Class _forceLinkExternalDisplaySceneDelegate = [ExternalDisplaySceneDelegate class]; @implementation AppDelegate diff --git a/ios/ExternalDisplaySceneDelegate.h b/ios/ExternalDisplaySceneDelegate.h new file mode 100644 index 0000000000..68470462cb --- /dev/null +++ b/ios/ExternalDisplaySceneDelegate.h @@ -0,0 +1,32 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#import + +// The scene iOS gives us for an external display, like an AirPlay TV. With Metal, the game goes there +// (a Game output, see docs/multi-swapchain.md), and the phone keeps the touch controls and the menus. +// Otherwise we leave the scene without a window, and iOS mirrors the phone. +@interface ExternalDisplaySceneDelegate : UIResponder + +@property (strong, nonatomic) UIWindow *window; + +@end + +// Called by the app's SceneDelegate when PPSSPP has (re)started, and the config is loaded. +void ExternalDisplayNotifyLaunched(); diff --git a/ios/ExternalDisplaySceneDelegate.mm b/ios/ExternalDisplaySceneDelegate.mm new file mode 100644 index 0000000000..95fcf03c8a --- /dev/null +++ b/ios/ExternalDisplaySceneDelegate.mm @@ -0,0 +1,161 @@ +// Copyright (c) 2012- PPSSPP Project. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 2.0 or later versions. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#import "ExternalDisplaySceneDelegate.h" + +#import + +#include + +#include "Common/GPU/thin3d.h" +#include "Common/Log.h" +#include "Common/System/NativeApp.h" +#include "Core/Config.h" +#include "Core/ConfigValues.h" + +static NSString *const PPSSPPDidLaunchNotification = @"PPSSPPDidLaunch"; + +// Set by the SceneDelegate. Before that, the config (and so the graphics backend) isn't known. +static bool g_launched = false; + +@interface ExternalDisplayView : UIView +@end + +@implementation ExternalDisplayView ++ (Class)layerClass { + return [CAMetalLayer class]; +} +@end + +// Adds the view's layer as a Game output once it has a size, and keeps the output's size up to date. +@interface ExternalDisplayViewController : UIViewController +@property (nonatomic) int outputId; +@end + +@implementation ExternalDisplayViewController { + int width_; + int height_; +} + +- (void)loadView { + self.view = [[ExternalDisplayView alloc] init]; + self.view.backgroundColor = UIColor.blackColor; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + UIScreen *screen = self.view.window.windowScene.screen; + const CGSize size = self.view.bounds.size; + CGFloat scale = screen ? screen.nativeScale : 1.0; + // Render no more than 1080p (a 4K Apple TV comes in at 3840x2160). The layer is scaled up to fill. + const CGFloat longSide = std::max(size.width, size.height); + const CGFloat shortSide = std::min(size.width, size.height); + if (longSide > 0 && shortSide > 0) { + scale = std::min(scale, std::min(1920.0 / longSide, 1080.0 / shortSide)); + } + self.view.contentScaleFactor = scale; + const int width = (int)(size.width * scale); + const int height = (int)(size.height * scale); + if (width <= 0 || height <= 0 || (width == width_ && height == height_)) { + return; + } + width_ = width; + height_ = height; + if (self.outputId) { + Native_ResizeOutput(self.outputId, width, height); + return; + } + Draw::SwapchainDesc desc{}; + desc.winsys = WINDOWSYSTEM_METAL_EXT; + desc.data1 = (__bridge void *)self.view.layer; + desc.width = width; + desc.height = height; + desc.tag = "ExternalDisplay"; + self.outputId = Native_AddOutput(OutputRole::Game, desc); + INFO_LOG(Log::G3D, "External display: %dx%d, output %d", width, height, self.outputId); +} + +@end + +@interface ExternalDisplaySceneDelegate () +@property (weak, nonatomic) UIWindowScene *windowScene; +@end + +@implementation ExternalDisplaySceneDelegate + +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions { + if (![scene isKindOfClass:[UIWindowScene class]]) { + return; + } + self.windowScene = (UIWindowScene *)scene; + INFO_LOG(Log::G3D, "External display connected"); + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didLaunch:) name:PPSSPPDidLaunchNotification object:nil]; + if (g_launched) { + [self attach]; + } +} + +- (void)didLaunch:(NSNotification *)notification { + // After a restart, the outputs are gone along with the old screen manager, and the backend may have changed. + [self detach]; + [self attach]; +} + +- (void)attach { + if (!self.windowScene || self.window) { + return; + } + // Only Metal can present to a second display so far. Without a window, iOS mirrors the phone. + if (g_Config.iGPUBackend != (int)GPUBackend::METAL) { + INFO_LOG(Log::G3D, "External display: mirroring, as the backend isn't Metal"); + 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)" : ""); + } +#endif + self.window = [[UIWindow alloc] initWithWindowScene:self.windowScene]; + self.window.rootViewController = [[ExternalDisplayViewController alloc] init]; + self.window.hidden = NO; +} + +- (void)detach { + if (!self.window) { + return; + } + ExternalDisplayViewController *viewController = (ExternalDisplayViewController *)self.window.rootViewController; + if (viewController.outputId) { + // The swapchain keeps the layer alive until the removal has gone through. + Native_RemoveOutput(viewController.outputId); + } + self.window = nil; +} + +- (void)sceneDidDisconnect:(UIScene *)scene { + INFO_LOG(Log::G3D, "External display disconnected"); + [[NSNotificationCenter defaultCenter] removeObserver:self]; + [self detach]; + self.windowScene = nil; +} + +@end + +void ExternalDisplayNotifyLaunched() { + g_launched = true; + [[NSNotificationCenter defaultCenter] postNotificationName:PPSSPPDidLaunchNotification object:nil]; +} diff --git a/ios/PPSSPP-Info.plist b/ios/PPSSPP-Info.plist index 2acc9d7cd8..42a0475167 100644 --- a/ios/PPSSPP-Info.plist +++ b/ios/PPSSPP-Info.plist @@ -229,6 +229,15 @@ SceneDelegate + UIWindowSceneSessionRoleExternalDisplayNonInteractive + + + UISceneConfigurationName + External Display + UISceneDelegateClassName + ExternalDisplaySceneDelegate + + diff --git a/ios/SceneDelegate.mm b/ios/SceneDelegate.mm index ba7c4e8704..a3d4bdcce6 100644 --- a/ios/SceneDelegate.mm +++ b/ios/SceneDelegate.mm @@ -2,6 +2,7 @@ #import "SceneDelegate.h" #import "AppDelegate.h" +#import "ExternalDisplaySceneDelegate.h" #import "iOSCoreAudio.h" #import "ViewController.h" @@ -198,6 +199,8 @@ static NSString *ExtractGameInfoScheme(NSURL *url) { self.window.rootViewController = [[PPSSPPViewController alloc] init]; [self.window makeKeyAndVisible]; + + ExternalDisplayNotifyLaunched(); } - (void)restart:(const char*)restartArgs { diff --git a/ios/ViewController.mm b/ios/ViewController.mm index 8befa1043e..facf2e65b6 100644 --- a/ios/ViewController.mm +++ b/ios/ViewController.mm @@ -1,6 +1,7 @@ #import "ios/ViewController.h" #import "ios/Controls.h" #import "ios/IAPManager.h" +#import "ios/ExternalDisplaySceneDelegate.h" #if !PPSSPP_PLATFORM(TVOS) #import "ios/CameraHelper.h" #import @@ -149,6 +150,8 @@ static void VulkanRenderLoop(GraphicsContext *graphicsContext, CAMetalLayer *met #if !PPSSPP_PLATFORM(TVOS) @property (strong, nonatomic) NSOperationQueue *accelerometerQueue; @property (strong, nonatomic) CMMotionManager *motionManager; +// The UISceneAccessoryRegistration for an external display, on iOS 27 and later. +@property (strong, nonatomic) id externalDisplayRegistration; #endif @end @@ -222,6 +225,15 @@ static int GetPickerRequestId(id picker) { - (void)shutdown { self.gameController = nil; +#if !PPSSPP_PLATFORM(TVOS) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 270000 + if (@available(iOS 27.0, *)) { + if (self.externalDisplayRegistration) { + // After a restart, the new view controller registers again if the backend can use it. + [self unregisterSceneAccessory:self.externalDisplayRegistration]; + self.externalDisplayRegistration = nil; + } + } +#endif [[NSNotificationCenter defaultCenter] removeObserver:self]; _dbg_assert_(sharedViewController != nil); @@ -634,6 +646,19 @@ static int GetPickerRequestId(id picker) { [self updateResolutionWithView:self.view]; +#if !PPSSPP_PLATFORM(TVOS) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 270000 + // From iOS 27, an external display scene only goes to apps that register for one like this. Earlier + // versions go by the Info.plist role instead. Only Metal can show the game there so far, and without + // the scene, iOS mirrors the phone. + if (@available(iOS 27.0, *)) { + if (g_Config.iGPUBackend == (int)GPUBackend::METAL) { + UISceneConfiguration *sceneConfiguration = [UISceneConfiguration new]; + sceneConfiguration.delegateClass = [ExternalDisplaySceneDelegate class]; + self.externalDisplayRegistration = [self registerSceneAccessory:[UISceneAccessory externalNonInteractiveSceneAccessoryWithConfiguration:sceneConfiguration]]; + } + } +#endif + [self setupFirstController]; INFO_LOG(Log::G3D, "Detected size: %dx%d", g_display.pixel_xres, g_display.pixel_yres); diff --git a/ios/main.mm b/ios/main.mm index c748f04d09..6c8b1920a1 100644 --- a/ios/main.mm +++ b/ios/main.mm @@ -492,13 +492,11 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string // Get the connected scenes NSSet *scenes = [UIApplication sharedApplication].connectedScenes; - // Loop through scenes to find a UIWindowScene that is active + // Find the app's scene. An external display has a scene of its own. for (UIScene *scene in scenes) { - if ([scene isKindOfClass:[UIWindowScene class]]) { - UIWindowScene *windowScene = (UIWindowScene *)scene; - SceneDelegate *sceneDelegate = (SceneDelegate *)windowScene.delegate; - [sceneDelegate restart:args.c_str()]; - break; // call only on the first active scene + if ([scene.delegate isKindOfClass:[SceneDelegate class]]) { + [(SceneDelegate *)scene.delegate restart:args.c_str()]; + break; } } });