mirror of
https://github.com/hrydgard/ppsspp.git
synced 2026-11-02 04:21:41 +01:00
iOS: Show the game on an AirPlay TV, with the controls on the phone
An external display (like an Apple TV over AirPlay) becomes a Game output, when the backend is Metal. The game is shown there full screen, and the phone shows the touch controls with "Playing on TV" in place of the game. The menus stay on the phone. With other backends, iOS keeps mirroring. Up to iOS 26 the external display scene comes from the Info.plist role. iOS 27 ignores that, and only offers the scene to apps that register a scene accessory for it, so we do that too. The registration is withdrawn when the view controller shuts down, so it follows a backend change, and a restart now goes to the app's scene rather than the first one. An Apple TV 4K only offers 3840x2160 over AirPlay, so the stream stays 4K, but we render at 1920x1080 and let the layer scale it up. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d491fc90cf
commit
7bd4b13baa
11 files changed
+261
-10
No files matched your search
@@ -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
|
||||
|
||||
+16
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <UIKit/UIKit.h>
|
||||
|
||||
// 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 <UIWindowSceneDelegate>
|
||||
|
||||
@property (strong, nonatomic) UIWindow *window;
|
||||
|
||||
@end
|
||||
|
||||
// Called by the app's SceneDelegate when PPSSPP has (re)started, and the config is loaded.
|
||||
void ExternalDisplayNotifyLaunched();
|
||||
@@ -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 <QuartzCore/CAMetalLayer.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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];
|
||||
}
|
||||
@@ -229,6 +229,15 @@
|
||||
<string>SceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIWindowSceneSessionRoleExternalDisplayNonInteractive</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>External Display</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>ExternalDisplaySceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <Photos/Photos.h>
|
||||
@@ -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);
|
||||
|
||||
+4
-6
@@ -492,13 +492,11 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string
|
||||
// Get the connected scenes
|
||||
NSSet<UIScene *> *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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user