From 625db43746237e30bf6e25cc4e5772f489b0adf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 13 Oct 2026 09:29:26 +0200 Subject: [PATCH] tvOS: Back on the main screen goes to the home screen, like on Android tvOS only does that with a Back press it gets to see, so the view controller lets controller events through to UIKit while the top screen is top level (the new TOP_LEVEL_CHANGED notification), and passes presses that aren't keyboard keys on to super. Elsewhere Back stays ours. Co-Authored-By: Claude Opus 5.5 --- Common/System/System.h | 1 + Common/UI/ScreenManager.cpp | 9 ++++++--- ios/ViewController.h | 1 + ios/ViewController.mm | 31 ++++++++++++++++++++++++++++++- ios/main.mm | 7 +++++++ 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/Common/System/System.h b/Common/System/System.h index 841549d2f2..1a9804457a 100644 --- a/Common/System/System.h +++ b/Common/System/System.h @@ -277,6 +277,7 @@ enum class SystemNotification { PAD_STATE_CHANGED, CONFIG_LOADED, BEFORE_CONFIG_SAVE_ON_EXIT, + TOP_LEVEL_CHANGED, // NativeIsAtTopLevel() changed. From the render thread. }; // 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/Common/UI/ScreenManager.cpp b/Common/UI/ScreenManager.cpp index f6c65750bb..bf741db430 100644 --- a/Common/UI/ScreenManager.cpp +++ b/Common/UI/ScreenManager.cpp @@ -1,4 +1,5 @@ #include "Common/System/Display.h" +#include "Common/System/System.h" #include "Common/Data/Collections/TinySet.h" #include "Common/UI/ScreenManager.h" #include "Common/UI/Root.h" @@ -133,13 +134,15 @@ void ScreenManager::Update() { overlayScreen_->update(); } + bool topLevel = false; if (!stack_.empty()) { Screen *backScreen = stack_.back().screen; backScreen->update(); passInputToMapper_ = backScreen->PassInputToMapper(); - topIsTopLevel_ = backScreen->isTopLevel(); - } else { - topIsTopLevel_ = false; + topLevel = backScreen->isTopLevel(); + } + if (topIsTopLevel_.exchange(topLevel) != topLevel) { + System_Notify(SystemNotification::TOP_LEVEL_CHANGED); } } diff --git a/ios/ViewController.h b/ios/ViewController.h index 69e2b27701..5350de0358 100644 --- a/ios/ViewController.h +++ b/ios/ViewController.h @@ -46,6 +46,7 @@ - (void)willResignActive; - (void)uiStateChanged; +- (void)topLevelChanged; - (void)pickPhoto:(NSString *)saveFilename requestId:(int)requestId; - (void)updateResolutionWithView:(UIView *)view; diff --git a/ios/ViewController.mm b/ios/ViewController.mm index 0e00e878e8..7d8e9bfdde 100644 --- a/ios/ViewController.mm +++ b/ios/ViewController.mm @@ -202,7 +202,7 @@ static int GetPickerRequestId(id picker) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerDidDisconnect:) name:GCControllerDidDisconnectNotification object:nil]; #if PPSSPP_PLATFORM(TVOS) - // Already the default. Should become YES at the top level menu, so the Menu button goes home. + // Already the default. See topLevelChanged. self.controllerUserInteractionEnabled = NO; #else // Observe orientation changes @@ -392,6 +392,15 @@ static int GetPickerRequestId(id picker) { } } +// Like Android, Back on the main screen leaves the app. tvOS only does that with a Back press it +// gets to see, so let controller events through to UIKit there. They still reach GCController. +- (void)topLevelChanged { +#if PPSSPP_PLATFORM(TVOS) + self.controllerUserInteractionEnabled = NativeIsAtTopLevel() ? YES : NO; + INFO_LOG(Log::System, "Controller events to UIKit: %s", self.controllerUserInteractionEnabled ? "yes" : "no"); +#endif +} + - (void)setupController:(GCController *)controller { self.gameController = controller; if (!InitController(controller)) { @@ -698,16 +707,36 @@ extern float g_safeInsetBottom; return YES; } +// Presses that aren't keyboard keys, like the remote's buttons, go on up the responder chain. On +// tvOS, that's what takes Back on the main screen to the home screen (see topLevelChanged). +static NSSet *NonKeyboardPresses(NSSet *presses) { + return [presses objectsPassingTest:^BOOL(UIPress *press, BOOL *stop) { + return press.key == nil; + }]; +} + - (void)pressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event { KeyboardPressesBegan(presses, event); + NSSet *others = NonKeyboardPresses(presses); + if (others.count) { + [super pressesBegan:others withEvent:event]; + } } - (void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event { KeyboardPressesEnded(presses, event); + NSSet *others = NonKeyboardPresses(presses); + if (others.count) { + [super pressesEnded:others withEvent:event]; + } } - (void)pressesCancelled:(NSSet *)presses withEvent:(UIPressesEvent *)event { KeyboardPressesEnded(presses, event); + NSSet *others = NonKeyboardPresses(presses); + if (others.count) { + [super pressesCancelled:others withEvent:event]; + } } #pragma mark - Status Bar Control diff --git a/ios/main.mm b/ios/main.mm index 85231886f8..a5134e0698 100644 --- a/ios/main.mm +++ b/ios/main.mm @@ -438,6 +438,13 @@ void System_Notify(SystemNotification notification) { } }); break; + case SystemNotification::TOP_LEVEL_CHANGED: + dispatch_async(dispatch_get_main_queue(), ^{ + if (sharedViewController) { + [sharedViewController topLevelChanged]; + } + }); + break; case SystemNotification::AUDIO_MODE_CHANGED: dispatch_async(dispatch_get_main_queue(), ^{ iOSCoreAudioUpdateSession();