diff --git a/Limelight/Input/Controller.h b/Limelight/Input/Controller.h index 6934697f..833284bb 100644 --- a/Limelight/Input/Controller.h +++ b/Limelight/Input/Controller.h @@ -6,7 +6,10 @@ // Copyright © 2019 Moonlight Game Streaming Project. All rights reserved. // +#import "HapticContext.h" + @import GameController; +@import CoreHaptics; @interface Controller : NSObject @@ -21,7 +24,8 @@ @property (nonatomic) short lastLeftStickY; @property (nonatomic) short lastRightStickX; @property (nonatomic) short lastRightStickY; -@property (nonatomic) unsigned short lowFreqMotor; -@property (nonatomic) unsigned short highFreqMotor; + +@property (nonatomic) HapticContext* _Nullable lowFreqMotor; +@property (nonatomic) HapticContext* _Nullable highFreqMotor; @end diff --git a/Limelight/Input/ControllerSupport.m b/Limelight/Input/ControllerSupport.m index 0327f423..b1214637 100644 --- a/Limelight/Input/ControllerSupport.m +++ b/Limelight/Input/ControllerSupport.m @@ -20,7 +20,6 @@ @implementation ControllerSupport { NSLock *_controllerStreamLock; NSMutableDictionary *_controllers; - NSTimer *_rumbleTimer; id _presenceDelegate; OnScreenControls *_osc; @@ -41,20 +40,6 @@ #define UPDATE_BUTTON_FLAG(controller, x, y) \ ((y) ? [self setButtonFlag:controller flags:x] : [self clearButtonFlag:controller flags:x]) --(void) rumbleController: (Controller*)controller -{ -#if 0 - // Only vibrate if the amplitude is large enough - if (controller.lowFreqMotor > 0x5000 || controller.highFreqMotor > 0x5000) { - // If the gamepad is nil (on-screen controls) or it's attached to the device, - // then vibrate the device itself - if (controller.gamepad == nil || [controller.gamepad isAttachedToDevice]) { - AudioServicesPlayAlertSound(kSystemSoundID_Vibrate); - } - } -#endif -} - -(void) rumble:(unsigned short)controllerNumber lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor { Controller* controller = [_controllers objectForKey:[NSNumber numberWithInteger:controllerNumber]]; @@ -67,12 +52,8 @@ return; } - // Update the motor levels for the rumble timer to grab next iteration - controller.lowFreqMotor = lowFreqMotor; - controller.highFreqMotor = highFreqMotor; - - // Rumble now to ensure short vibrations aren't missed - [self rumbleController:controller]; + [controller.lowFreqMotor setMotorAmplitude:lowFreqMotor]; + [controller.highFreqMotor setMotorAmplitude:highFreqMotor]; } -(void) updateLeftStick:(Controller*)controller x:(short)x y:(short)y @@ -212,6 +193,18 @@ } } +-(void) initializeControllerHaptics:(Controller*) controller +{ + controller.lowFreqMotor = [HapticContext createContextForLowFreqMotor:controller.gamepad]; + controller.highFreqMotor = [HapticContext createContextForHighFreqMotor:controller.gamepad]; +} + +-(void) cleanupControllerHaptics:(Controller*) controller +{ + [controller.lowFreqMotor cleanup]; + [controller.highFreqMotor cleanup]; +} + -(void) registerControllerCallbacks:(GCController*) controller { if (controller != NULL) { @@ -401,6 +394,9 @@ limeController.supportedEmulationFlags &= ~EMULATING_SELECT; } } + + // Prepare controller haptics for use + [self initializeControllerHaptics:limeController]; [_controllers setObject:limeController forKey:[NSNumber numberWithInteger:controller.playerIndex]]; @@ -461,23 +457,6 @@ return mask; } --(void) rumbleTimer -{ - for (int i = 0; i < 4; i++) { - Controller* controller = [_controllers objectForKey:[NSNumber numberWithInteger:i]]; - if (controller == nil && i == 0 && _oscEnabled) { - // No physical controller, but we have on-screen controls - controller = _player0osc; - } - if (controller == nil) { - // No connected controller for this player - continue; - } - - [self rumbleController:controller]; - } -} - -(NSUInteger) getConnectedGamepadCount { return _controllers.count; @@ -499,12 +478,6 @@ DataManager* dataMan = [[DataManager alloc] init]; _oscEnabled = (OnScreenControlsLevel)[[dataMan getSettings].onscreenControls integerValue] != OnScreenControlsLevelOff; - _rumbleTimer = [NSTimer scheduledTimerWithTimeInterval:0.20 - target:self - selector:@selector(rumbleTimer) - userInfo:nil - repeats:YES]; - Log(LOG_I, @"Number of supported controllers connected: %d", [ControllerSupport getGamepadCount]); Log(LOG_I, @"Multi-controller: %d", _multiController); @@ -553,6 +526,10 @@ // Unset the GCController on this object (in case it is the OSC, which will persist) Controller* limeController = [self->_controllers objectForKey:[NSNumber numberWithInteger:controller.playerIndex]]; + + // Stop haptics on this controller + [self cleanupControllerHaptics:limeController]; + limeController.gamepad = nil; // Inform the server of the updated active gamepads before removing this controller @@ -570,12 +547,15 @@ -(void) cleanup { - [_rumbleTimer invalidate]; - _rumbleTimer = nil; [[NSNotificationCenter defaultCenter] removeObserver:self.connectObserver]; [[NSNotificationCenter defaultCenter] removeObserver:self.disconnectObserver]; - [_controllers removeAllObjects]; _controllerNumbers = 0; + + for (Controller* controller in [_controllers allValues]) { + [self cleanupControllerHaptics:controller]; + } + [_controllers removeAllObjects]; + for (GCController* controller in [GCController controllers]) { if ([ControllerSupport isSupportedGamepad:controller]) { [self unregisterControllerCallbacks:controller]; diff --git a/Limelight/Input/HapticContext.h b/Limelight/Input/HapticContext.h new file mode 100644 index 00000000..69f9d5c3 --- /dev/null +++ b/Limelight/Input/HapticContext.h @@ -0,0 +1,20 @@ +// +// HapticContext.h +// Moonlight +// +// Created by Cameron Gutman on 9/17/20. +// Copyright © 2020 Moonlight Game Streaming Project. All rights reserved. +// + +@import CoreHaptics; +@import GameController; + +@interface HapticContext : NSObject + +-(void)setMotorAmplitude:(unsigned short)amplitude; +-(void)cleanup; + ++(HapticContext*) createContextForHighFreqMotor:(GCController*)gamepad; ++(HapticContext*) createContextForLowFreqMotor:(GCController*)gamepad; + +@end diff --git a/Limelight/Input/HapticContext.m b/Limelight/Input/HapticContext.m new file mode 100644 index 00000000..03e5fb0e --- /dev/null +++ b/Limelight/Input/HapticContext.m @@ -0,0 +1,131 @@ +// +// HapticContext.m +// Moonlight +// +// Created by Cameron Gutman on 9/17/20. +// Copyright © 2020 Moonlight Game Streaming Project. All rights reserved. +// + +#import "HapticContext.h" + +@import CoreHaptics; +@import GameController; + +@implementation HapticContext { + GCControllerPlayerIndex _playerIndex; + CHHapticEngine* _hapticEngine API_AVAILABLE(ios(13.0)); + id _hapticPlayer API_AVAILABLE(ios(13.0)); +} + +-(void)cleanup API_AVAILABLE(ios(14.0)) { + if (_hapticPlayer != nil) { + [_hapticPlayer cancelAndReturnError:nil]; + _hapticPlayer = nil; + } + if (_hapticEngine != nil) { + [_hapticEngine stopWithCompletionHandler:nil]; + _hapticEngine = nil; + } +} + +-(void)setMotorAmplitude:(unsigned short)amplitude API_AVAILABLE(ios(14.0)) { + NSError* error; + + // Cancel the last haptic effect + if (_hapticPlayer != nil) { + [_hapticPlayer stopAtTime:0 error:&error]; + _hapticPlayer = nil; + } + + // Check if the haptic engine died + if (_hapticEngine == nil) { + return; + } + + // Don't bother queuing a 0 amplitude haptic event + if (amplitude == 0) { + return; + } + + CHHapticEventParameter* intensityParameter = [[CHHapticEventParameter alloc] initWithParameterID:CHHapticEventParameterIDHapticIntensity value:amplitude / 65536.0f]; + CHHapticEvent* hapticEvent = [[CHHapticEvent alloc] initWithEventType:CHHapticEventTypeHapticContinuous parameters:[NSArray arrayWithObjects:intensityParameter, nil] relativeTime:0 duration:GCHapticDurationInfinite]; + CHHapticPattern* hapticPattern = [[CHHapticPattern alloc] initWithEvents:[NSArray arrayWithObject:hapticEvent] parameters:[[NSArray alloc] init] error:&error]; + if (error != nil) { + Log(LOG_W, @"Controller %d: Haptic pattern creation failed: %@", _playerIndex, error); + return; + } + + _hapticPlayer = [_hapticEngine createPlayerWithPattern:hapticPattern error:&error]; + if (error != nil) { + Log(LOG_W, @"Controller %d: Haptic player creation failed: %@", _playerIndex, error); + return; + } + + [_hapticPlayer startAtTime:0 error:&error]; + if (error != nil) { + _hapticPlayer = nil; + Log(LOG_W, @"Controller %d: Haptic playback start failed: %@", _playerIndex, error); + return; + } +} + +-(id) initWithGamepad:(GCController*)gamepad locality:(GCHapticsLocality)locality API_AVAILABLE(ios(14.0)) { + if (gamepad.haptics == nil) { + Log(LOG_W, @"Controller %d does not support haptics", gamepad.playerIndex); + return nil; + } + + _playerIndex = gamepad.playerIndex; + _hapticEngine = [gamepad.haptics createEngineWithLocality:locality]; + + NSError* error; + [_hapticEngine startAndReturnError:&error]; + if (error != nil) { + Log(LOG_W, @"Controller %d: Haptic engine failed to start: %@", gamepad.playerIndex, error); + return nil; + } + + __weak typeof(self) weakSelf = self; + _hapticEngine.stoppedHandler = ^(CHHapticEngineStoppedReason stoppedReason) { + HapticContext* me = weakSelf; + if (me == nil) { + return; + } + + Log(LOG_W, @"Controller %d: Haptic engine stopped: %p", me->_playerIndex, stoppedReason); + me->_hapticPlayer = nil; + me->_hapticEngine = nil; + }; + _hapticEngine.resetHandler = ^{ + HapticContext* me = weakSelf; + if (me == nil) { + return; + } + + Log(LOG_W, @"Controller %d: Haptic engine reset", me->_playerIndex); + me->_hapticPlayer = nil; + [me->_hapticEngine startAndReturnError:nil]; + }; + + return self; +} + ++(HapticContext*) createContextForHighFreqMotor:(GCController*)gamepad { + if (@available(iOS 14.0, tvOS 14.0, *)) { + return [[HapticContext alloc] initWithGamepad:gamepad locality:GCHapticsLocalityRightHandle]; + } + else { + return nil; + } +} + ++(HapticContext*) createContextForLowFreqMotor:(GCController*)gamepad { + if (@available(iOS 14.0, tvOS 14.0, *)) { + return [[HapticContext alloc] initWithGamepad:gamepad locality:GCHapticsLocalityLeftHandle]; + } + else { + return nil; + } +} + +@end diff --git a/Moonlight.xcodeproj/project.pbxproj b/Moonlight.xcodeproj/project.pbxproj index 6c17db9b..e776883c 100644 --- a/Moonlight.xcodeproj/project.pbxproj +++ b/Moonlight.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 693B3A9B218638CD00982F7B /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 693B3A9A218638CD00982F7B /* Settings.bundle */; }; + 9827E7A32514366900F25707 /* HapticContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 9827E7A22514366900F25707 /* HapticContext.m */; }; + 9827E7A42514366900F25707 /* HapticContext.m in Sources */ = {isa = PBXBuildFile; fileRef = 9827E7A22514366900F25707 /* HapticContext.m */; }; 9832D1361BBCD5C50036EF48 /* TemporaryApp.m in Sources */ = {isa = PBXBuildFile; fileRef = 9832D1351BBCD5C50036EF48 /* TemporaryApp.m */; }; 9865DC30213260B40005B9B9 /* libmoonlight-common-tv.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FB1A68152132509400507771 /* libmoonlight-common-tv.a */; }; 9865DC31213260F10005B9B9 /* mkcert.c in Sources */ = {isa = PBXBuildFile; fileRef = FB89460719F646E200339C8A /* mkcert.c */; }; @@ -157,6 +159,8 @@ /* Begin PBXFileReference section */ 693B3A9A218638CD00982F7B /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; 98132E8C20BC9A62007A053F /* Moonlight v1.1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.1.xcdatamodel"; sourceTree = ""; }; + 9827E7A22514366900F25707 /* HapticContext.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HapticContext.m; sourceTree = ""; }; + 9827E7A7251436EA00F25707 /* HapticContext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HapticContext.h; sourceTree = ""; }; 9832D1341BBCD5C50036EF48 /* TemporaryApp.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = TemporaryApp.h; path = Database/TemporaryApp.h; sourceTree = ""; }; 9832D1351BBCD5C50036EF48 /* TemporaryApp.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TemporaryApp.m; path = Database/TemporaryApp.m; sourceTree = ""; }; 98517B1B21CE0A9000481377 /* Moonlight v1.3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.3.xcdatamodel"; sourceTree = ""; }; @@ -533,6 +537,8 @@ FB1A674C2131E65900507771 /* KeyboardSupport.m */, 9897B6A0221260EF00966419 /* Controller.m */, 9897B6A32212610800966419 /* Controller.h */, + 9827E7A22514366900F25707 /* HapticContext.m */, + 9827E7A7251436EA00F25707 /* HapticContext.h */, ); path = Input; sourceTree = ""; @@ -945,6 +951,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9827E7A42514366900F25707 /* HapticContext.m in Sources */, 9897B6A62212732C00966419 /* Controller.m in Sources */, 9865DC3E21332D660005B9B9 /* MainFrameViewController.m in Sources */, 9865DC36213287F30005B9B9 /* AppDelegate.m in Sources */, @@ -1023,6 +1030,7 @@ FBD1C8E21A8AD71400C6703C /* Logger.m in Sources */, FB1D599A1BBCCD7E00F482CA /* AppCollectionView.m in Sources */, FB89463619F646E200339C8A /* StreamFrameViewController.m in Sources */, + 9827E7A32514366900F25707 /* HapticContext.m in Sources */, 9832D1361BBCD5C50036EF48 /* TemporaryApp.m in Sources */, 98D585701C0ED0E800F6CC00 /* TemporarySettings.m in Sources */, FB89462819F646E200339C8A /* CryptoManager.m in Sources */,