diff --git a/ios/AppDelegate.mm b/ios/AppDelegate.mm index bd9d44ac96..be35453073 100644 --- a/ios/AppDelegate.mm +++ b/ios/AppDelegate.mm @@ -82,6 +82,14 @@ __attribute__((used)) static Class _forceLinkSceneDelegate = [SceneDelegate clas iOSCoreAudioInit(); } +// for AVAudioSessionRouteChangeNotification +-(void) handleAudioRouteChange:(NSNotification *)notification { + NSNumber *reason = notification.userInfo[AVAudioSessionRouteChangeReasonKey]; + char buf[32]; + snprintf(buf, sizeof(buf), "change, reason %d", reason.intValue); + iOSCoreAudioLogRoute(buf); +} + -(BOOL) application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.launchOptions = launchOptions; @@ -91,6 +99,7 @@ __attribute__((used)) static Class _forceLinkSceneDelegate = [SceneDelegate clas [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAudioSessionInterruption:) name:AVAudioSessionInterruptionNotification object:[AVAudioSession sharedInstance]]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleMediaServicesWereReset:) name:AVAudioSessionMediaServicesWereResetNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAudioRouteChange:) name:AVAudioSessionRouteChangeNotification object:nil]; return YES; } diff --git a/ios/Controls.h b/ios/Controls.h index cb865ae1c8..181d99e557 100644 --- a/ios/Controls.h +++ b/ios/Controls.h @@ -26,6 +26,8 @@ public: void Moved(NSSet *touches, UIView *view); void Ended(NSSet *touches, UIView *view); void Cancelled(NSSet *touches, UIView *view); + // Lifts every touch still down. UIKit doesn't always end the touches that pull down Control Center. + void CancelAll(); private: void SendTouchEvent(CGPoint point, UIView *view, int code, int pointerId); int ToTouchID(UITouch *uiTouch, bool allowAllocate); diff --git a/ios/Controls.mm b/ios/Controls.mm index c7ad099ffa..11864756be 100644 --- a/ios/Controls.mm +++ b/ios/Controls.mm @@ -381,6 +381,18 @@ void TouchTracker::Cancelled(NSSet *touches, UIView *view) { } } +void TouchTracker::CancelAll() { + for (int touchId = 0; touchId < (int)ARRAY_SIZE(touches_); ++touchId) { + if (touches_[touchId]) { + TouchInput input{}; + input.flags = TouchInputFlags::UP | TouchInputFlags::CANCEL; + input.id = touchId; + NativeTouch(input); + touches_[touchId] = nullptr; + } + } +} + #if !PPSSPP_PLATFORM(TVOS) void ProcessAccelerometerData(CMAccelerometerData *accData) { CMAcceleration acc = accData.acceleration; diff --git a/ios/ViewController.mm b/ios/ViewController.mm index 9cb97e139e..8befa1043e 100644 --- a/ios/ViewController.mm +++ b/ios/ViewController.mm @@ -337,6 +337,10 @@ static int GetPickerRequestId(id picker) { - (void)willResignActive { [self requestExitVulkanRenderLoop]; +#if !PPSSPP_PLATFORM(TVOS) + g_touchTracker.CancelAll(); +#endif + #if !PPSSPP_PLATFORM(TVOS) // Stop accelerometer updates if (self.motionManager.accelerometerActive) { diff --git a/ios/iOSCoreAudio.h b/ios/iOSCoreAudio.h index 3a0fb5427f..ac6e4e69ec 100644 --- a/ios/iOSCoreAudio.h +++ b/ios/iOSCoreAudio.h @@ -25,6 +25,9 @@ void iOSCoreAudioShutdown(); // Applies the audio settings (mix with others, respect silent mode) to the session. void iOSCoreAudioUpdateSession(); +// Logs where the session's output currently goes (speaker, headphones, AirPlay...). +void iOSCoreAudioLogRoute(const char *reason); + // Microphone input, for sceUsbMic. These are called on the CPU thread. Input is buffered, and // iOSCoreAudioPollRecording hands it over to Microphone::addAudioData. void iOSCoreAudioStartRecording(int sampleRate); diff --git a/ios/iOSCoreAudio.mm b/ios/iOSCoreAudio.mm index 6522524b39..3293af693a 100644 --- a/ios/iOSCoreAudio.mm +++ b/ios/iOSCoreAudio.mm @@ -65,13 +65,17 @@ void iOSCoreAudioUpdateSession() { #else // Hacky hack to force iOS to re-evaluate. // Switching from CatogoryPlayback to CategoryPlayback with an option otherwise does nothing. - [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryAudioProcessing error:&error]; + NSError *hackError = nil; + if (![[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryAudioProcessing error:&hackError]) { + NSLog(@"Switching to AudioProcessing failed: %@", hackError); + } // Here, we apply the settings. if (g_micEnabled) { // Recording needs PlayAndRecord, which ignores the silent switch. A2DP rather than the // hands-free profile keeps Bluetooth headphones at full quality, with the built-in mic as input. - AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetoothA2DP; + // Unlike the playback categories, PlayAndRecord needs to be told to allow AirPlay. + AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetoothA2DP | AVAudioSessionCategoryOptionAllowAirPlay; if (g_Config.bAudioMixWithOthers) { options |= AVAudioSessionCategoryOptionMixWithOthers; } @@ -93,7 +97,17 @@ void iOSCoreAudioUpdateSession() { #endif if (error) { - NSLog(@"%@", error); + NSLog(@"Setting the audio session category failed: %@", error); + } +} + +void iOSCoreAudioLogRoute(const char *reason) { + AVAudioSession *session = [AVAudioSession sharedInstance]; + for (AVAudioSessionPortDescription *port in session.currentRoute.outputs) { + NSLog(@"Audio route (%s): %@ '%@' (category %@, options %d)", reason, port.portType, port.portName, session.category, (int)session.categoryOptions); + } + for (AVAudioSessionPortDescription *port in session.currentRoute.inputs) { + NSLog(@"Audio input (%s): %@ '%@'", reason, port.portType, port.portName); } } @@ -204,6 +218,8 @@ void iOSCoreAudioInit() { } } + iOSCoreAudioLogRoute("init"); + if (audioInstance) { // Already running return; @@ -307,14 +323,15 @@ void iOSCoreAudioShutdown() // Brings the unit in line with g_micEnabled. Main thread. static void UpdateMicrophone() { - iOSCoreAudioUpdateSession(); if (!audioInstance) { + // Sets up the session too. iOSCoreAudioInit(); return; } if (!g_Config.bEnableSound && !g_micEnabled) { // The unit was only running for the microphone. iOSCoreAudioShutdown(); + iOSCoreAudioUpdateSession(); return; } if (g_micEnabled == g_micInputOn && (!g_micEnabled || g_micSampleRate == g_micInputRate)) { @@ -323,6 +340,8 @@ static void UpdateMicrophone() { AudioOutputUnitStop(audioInstance); AudioUnitUninitialize(audioInstance); + // Only with the unit stopped: iOS refuses (with '!pri') to leave PlayAndRecord while the input runs. + iOSCoreAudioUpdateSession(); ConfigureInput(audioInstance, g_micEnabled); OSStatus err = AudioUnitInitialize(audioInstance); if (err == noErr) { diff --git a/ios/main.mm b/ios/main.mm index f05106bb91..c748f04d09 100644 --- a/ios/main.mm +++ b/ios/main.mm @@ -506,12 +506,12 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string } case SystemRequestType::EXIT_APP: // NOTE: on iOS, this is considered a crash and not a valid way to exit. - exit(0); - // The below seems right, but causes hangs. See #12140. - // dispatch_async(dispatch_get_main_queue(), ^{ - // [sharedViewController shutdown]; - // exit(0); - // }); + // We're on the render thread. Calling exit() here would destroy its still joinable std::thread, + // which aborts, so let the main thread join it first. + dispatch_async(dispatch_get_main_queue(), ^{ + [sharedViewController shutdown]; + exit(0); + }); break; case SystemRequestType::BROWSE_FOR_FILE: {