diff --git a/Limelight/AppDelegate.m b/Limelight/AppDelegate.m index 5db73f57d..1e9b0c7a8 100644 --- a/Limelight/AppDelegate.m +++ b/Limelight/AppDelegate.m @@ -23,22 +23,10 @@ @implementation AppDelegate #endif - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { -#if !TARGET_OS_TV - UIApplicationShortcutItem* shortcut = [launchOptions valueForKey:UIApplicationLaunchOptionsShortcutItemKey]; - if (shortcut != nil) { - _pcUuidToLoad = (NSString*)[shortcut.userInfo objectForKey:@"UUID"]; - } -#endif + // On iOS, quick actions are delivered to our SceneDelegate return YES; } -#if !TARGET_OS_TV -- (void)application:(UIApplication *)application performActionForShortcutItem:(UIApplicationShortcutItem *)shortcutItem completionHandler:(void (^)(BOOL succeeded))completionHandler { - _pcUuidToLoad = (NSString*)[shortcutItem.userInfo objectForKey:@"UUID"]; - _shortcutCompletionHandler = completionHandler; -} -#endif - - (void)applicationWillResignActive:(UIApplication *)application { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. diff --git a/Limelight/Database/DataManager.h b/Limelight/Database/DataManager.h index 5717a7954..68dfdf5bd 100644 --- a/Limelight/Database/DataManager.h +++ b/Limelight/Database/DataManager.h @@ -28,7 +28,8 @@ enableHdr:(BOOL)enableHdr btMouseSupport:(BOOL)btMouseSupport absoluteTouchMode:(BOOL)absoluteTouchMode - statsOverlay:(BOOL)statsOverlay; + statsOverlay:(BOOL)statsOverlay + videoScalingMode:(uint32_t)videoScalingMode; - (NSArray*) getHosts; - (void) updateHost:(TemporaryHost*)host; diff --git a/Limelight/Database/DataManager.m b/Limelight/Database/DataManager.m index 0f5edeb19..3d1d594c7 100644 --- a/Limelight/Database/DataManager.m +++ b/Limelight/Database/DataManager.m @@ -67,7 +67,8 @@ - (void) saveSettingsWithBitrate:(NSInteger)bitrate enableHdr:(BOOL)enableHdr btMouseSupport:(BOOL)btMouseSupport absoluteTouchMode:(BOOL)absoluteTouchMode - statsOverlay:(BOOL)statsOverlay { + statsOverlay:(BOOL)statsOverlay + videoScalingMode:(uint32_t)videoScalingMode { [_managedObjectContext performBlockAndWait:^{ Settings* settingsToSave = [self retrieveSettings]; @@ -87,7 +88,8 @@ - (void) saveSettingsWithBitrate:(NSInteger)bitrate settingsToSave.btMouseSupport = btMouseSupport; settingsToSave.absoluteTouchMode = absoluteTouchMode; settingsToSave.statsOverlay = statsOverlay; - + settingsToSave.videoScalingMode = videoScalingMode; + [self saveData]; }]; } diff --git a/Limelight/Database/TemporarySettings.h b/Limelight/Database/TemporarySettings.h index da35b30c1..cbef23d4e 100644 --- a/Limelight/Database/TemporarySettings.h +++ b/Limelight/Database/TemporarySettings.h @@ -8,6 +8,12 @@ #import "Settings+CoreDataClass.h" +// Special values for the width and height settings that select the full screen or +// safe area resolution of whichever display the stream is started on. These allow +// a single setting to match both the inner and outer displays of iPhone Duo. +#define RESOLUTION_MATCH_DISPLAY_FULL -1 +#define RESOLUTION_MATCH_DISPLAY_SAFE_AREA -2 + @interface TemporarySettings : NSObject @property (nonatomic, retain) Settings * parent; @@ -34,6 +40,11 @@ @property (nonatomic) BOOL btMouseSupport; @property (nonatomic) BOOL absoluteTouchMode; @property (nonatomic) BOOL statsOverlay; +@property (nonatomic) enum { + VIDEO_SCALING_FIT, + VIDEO_SCALING_FILL, + VIDEO_SCALING_STRETCH, +} videoScalingMode; - (id) initFromSettings:(Settings*)settings; diff --git a/Limelight/Database/TemporarySettings.m b/Limelight/Database/TemporarySettings.m index e627e0c1f..4f3fad70e 100644 --- a/Limelight/Database/TemporarySettings.m +++ b/Limelight/Database/TemporarySettings.m @@ -85,6 +85,7 @@ - (id) initFromSettings:(Settings*)settings { self.btMouseSupport = settings.btMouseSupport; self.absoluteTouchMode = settings.absoluteTouchMode; self.statsOverlay = settings.statsOverlay; + self.videoScalingMode = (typeof(self.videoScalingMode))settings.videoScalingMode; #endif self.uniqueId = settings.uniqueId; diff --git a/Limelight/Input/ControllerSupport.h b/Limelight/Input/ControllerSupport.h index a805c6701..dc4b5bd36 100644 --- a/Limelight/Input/ControllerSupport.h +++ b/Limelight/Input/ControllerSupport.h @@ -50,4 +50,7 @@ -(NSUInteger) getConnectedGamepadCount; +// Whether GCMouse has delivered mouse button events since a mouse was last disconnected +-(BOOL) hasReceivedMouseButtonEvents; + @end diff --git a/Limelight/Input/ControllerSupport.m b/Limelight/Input/ControllerSupport.m index 19d131578..b2916d2c0 100644 --- a/Limelight/Input/ControllerSupport.m +++ b/Limelight/Input/ControllerSupport.m @@ -31,6 +31,7 @@ @implementation ControllerSupport { NSMutableDictionary *_controllers; id _delegate; + BOOL _receivedMouseButtonEvents; float accumulatedDeltaX; float accumulatedDeltaY; float accumulatedScrollX; @@ -863,23 +864,28 @@ -(void) registerMouseCallbacks:(GCMouse*) mouse API_AVAILABLE(ios(14.0)) { }; mouse.mouseInput.leftButton.pressedChangedHandler = ^(GCControllerButtonInput * _Nonnull button, float value, BOOL pressed) { + self->_receivedMouseButtonEvents = YES; LiSendMouseButtonEvent(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_LEFT); }; mouse.mouseInput.middleButton.pressedChangedHandler = ^(GCControllerButtonInput * _Nonnull button, float value, BOOL pressed) { + self->_receivedMouseButtonEvents = YES; LiSendMouseButtonEvent(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_MIDDLE); }; mouse.mouseInput.rightButton.pressedChangedHandler = ^(GCControllerButtonInput * _Nonnull button, float value, BOOL pressed) { + self->_receivedMouseButtonEvents = YES; LiSendMouseButtonEvent(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_RIGHT); }; if (mouse.mouseInput.auxiliaryButtons != nil) { if (mouse.mouseInput.auxiliaryButtons.count >= 1) { mouse.mouseInput.auxiliaryButtons[0].pressedChangedHandler = ^(GCControllerButtonInput * _Nonnull button, float value, BOOL pressed) { + self->_receivedMouseButtonEvents = YES; LiSendMouseButtonEvent(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_X1); }; } if (mouse.mouseInput.auxiliaryButtons.count >= 2) { mouse.mouseInput.auxiliaryButtons[1].pressedChangedHandler = ^(GCControllerButtonInput * _Nonnull button, float value, BOOL pressed) { + self->_receivedMouseButtonEvents = YES; LiSendMouseButtonEvent(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_X2); }; } @@ -1068,6 +1074,11 @@ +(int) getConnectedGamepadMask:(StreamConfiguration*)streamConfig { return mask; } +-(BOOL) hasReceivedMouseButtonEvents +{ + return _receivedMouseButtonEvents; +} + -(NSUInteger) getConnectedGamepadCount { return _controllers.count; @@ -1199,6 +1210,7 @@ -(id) initWithConfig:(StreamConfiguration*)streamConfig delegate:(id_receivedMouseButtonEvents = NO; // Re-evaluate the on-screen control mode [self updateAutoOnScreenControlMode]; diff --git a/Limelight/Input/OnScreenControls.h b/Limelight/Input/OnScreenControls.h index 29997de34..0b9e13763 100644 --- a/Limelight/Input/OnScreenControls.h +++ b/Limelight/Input/OnScreenControls.h @@ -32,5 +32,6 @@ typedef NS_ENUM(NSInteger, OnScreenControlsLevel) { - (void) setLevel:(OnScreenControlsLevel)level; - (OnScreenControlsLevel) getLevel; - (void) show; +- (void) updateLayout; @end diff --git a/Limelight/Input/OnScreenControls.m b/Limelight/Input/OnScreenControls.m index 846492a1c..273cbeb14 100644 --- a/Limelight/Input/OnScreenControls.m +++ b/Limelight/Input/OnScreenControls.m @@ -59,8 +59,10 @@ @implementation OnScreenControls { BOOL l3Set; BOOL r3Set; - BOOL _iPad; + BOOL _largeScreen; CGRect _controlArea; + CGSize _layoutSize; + NSArray* _divisionFrames; UIView* _view; OnScreenControlsLevel _level; BOOL _visible; @@ -119,21 +121,9 @@ - (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSu _controller = [controllerSupport getOscController]; _deadTouches = [[NSMutableArray alloc] init]; _swapABXY = streamConfig.swapABXYButtons; - - _iPad = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad); - _controlArea = CGRectMake(0, 0, _view.frame.size.width, _view.frame.size.height); - if (_iPad) - { - // Cut down the control area on an iPad so the controls are more reachable - _controlArea.size.height = _view.frame.size.height / 2.0; - _controlArea.origin.y = _view.frame.size.height - _controlArea.size.height; - } - else - { - _controlArea.origin.x = _controlArea.size.width * EDGE_WIDTH; - _controlArea.size.width -= _controlArea.origin.x * 2; - } - + + [self updateControlArea]; + _aButton = [CALayer layer]; _bButton = [CALayer layer]; _xButton = [CALayer layer]; @@ -158,6 +148,125 @@ - (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSu return self; } +- (BOOL) isLargeScreen { + // Use the large screen layout on iPads and on other displays that are regular-sized + // in both dimensions, like the inner display of iPhone Duo. The phone layout would + // put the controls far from the user's thumbs and close to the fold on those displays. + return [UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad || + (_view.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular && + _view.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassRegular); +} + +- (void) updateControlArea { + _largeScreen = [self isLargeScreen]; + _controlArea = CGRectMake(0, 0, _view.frame.size.width, _view.frame.size.height); + if (_largeScreen) + { + // Cut down the control area on large screens so the controls are more reachable + _controlArea.size.height = _view.frame.size.height / 2.0; + _controlArea.origin.y = _view.frame.size.height - _controlArea.size.height; + } + else + { + _controlArea.origin.x = _controlArea.size.width * EDGE_WIDTH; + _controlArea.size.width -= _controlArea.origin.x * 2; + } +} + +// Returns the frames of the active vertical division regions of our view, like the +// fold of iPhone Duo's inner display. Divisions are only active while partially folded. +- (NSArray*) getDivisionFrames { + NSMutableArray* frames = [[NSMutableArray alloc] init]; +#if defined(__IPHONE_27_1) && !TARGET_OS_TV + if (@available(iOS 27.1, *)) { + for (UIViewReservedRegion* region in [_view reservedRegionsOfKind:[UIViewReservedRegionKind divisionRegionKind]]) { + // We only move controls horizontally, so ignore horizontal divisions + if (region.frame.size.height > region.frame.size.width) { + [frames addObject:[NSValue valueWithCGRect:region.frame]]; + } + } + } +#endif + return frames; +} + +// This must be called from our view's layoutSubviews. UIKit lays the view out again +// when the reserved regions read during layout change, so this also gets called when +// the device is folded or unfolded without the size of the view changing. +- (void) updateLayout { + NSArray* divisionFrames = [self getDivisionFrames]; + if (CGSizeEqualToSize(_view.bounds.size, _layoutSize) && + [divisionFrames isEqualToArray:_divisionFrames] && + [self isLargeScreen] == _largeScreen) { + return; + } + _layoutSize = _view.bounds.size; + _divisionFrames = divisionFrames; + + [self updateControlArea]; + + // Only update controls if we're showing, otherwise + // show will do it for us. + if (_visible) { + // Move the controls immediately rather than animating them, since + // touches are hit tested against their on-screen positions + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + [self updateControls]; + + // Redrawing the sticks centers them, so put any held stick back + // under the user's finger + NSMutableSet* stickTouches = [[NSMutableSet alloc] init]; + if (_lsTouch != nil) { + [stickTouches addObject:_lsTouch]; + } + if (_rsTouch != nil) { + [stickTouches addObject:_rsTouch]; + } + if (stickTouches.count > 0) { + [self handleTouchMovedEvent:stickTouches]; + } + [CATransaction commit]; + } +} + +// Returns an X coordinate for a control of the given width that keeps it off of +// the division regions found during our last layout. +- (float) xAvoidingDivisions:(float)x width:(float)width { + for (NSValue* value in _divisionFrames) { + CGRect regionFrame = value.CGRectValue; + if (x + width / 2 > CGRectGetMinX(regionFrame) && x - width / 2 < CGRectGetMaxX(regionFrame)) { + // Move the control to whichever side of the division its center is on + if (x < CGRectGetMidX(regionFrame)) { + x = CGRectGetMinX(regionFrame) - width / 2; + } + else { + x = CGRectGetMaxX(regionFrame) + width / 2; + } + } + } + return x; +} + +- (void) avoidDivisionRegions { + float dpadWidth = (D_PAD_DIST + [UIImage imageNamed:@"LeftButton"].size.width) * 2; + float buttonsWidth = (BUTTON_DIST + [UIImage imageNamed:@"XButton"].size.width) * 2; + float stickWidth = [UIImage imageNamed:@"StickOuter"].size.width; + + D_PAD_CENTER_X = [self xAvoidingDivisions:D_PAD_CENTER_X width:dpadWidth]; + BUTTON_CENTER_X = [self xAvoidingDivisions:BUTTON_CENTER_X width:buttonsWidth]; + LS_CENTER_X = [self xAvoidingDivisions:LS_CENTER_X width:stickWidth]; + RS_CENTER_X = [self xAvoidingDivisions:RS_CENTER_X width:stickWidth]; + START_X = [self xAvoidingDivisions:START_X width:[UIImage imageNamed:@"StartButton"].size.width]; + SELECT_X = [self xAvoidingDivisions:SELECT_X width:[UIImage imageNamed:@"SelectButton"].size.width]; + L1_X = [self xAvoidingDivisions:L1_X width:[UIImage imageNamed:@"L1"].size.width]; + R1_X = [self xAvoidingDivisions:R1_X width:[UIImage imageNamed:@"R1"].size.width]; + L2_X = [self xAvoidingDivisions:L2_X width:[UIImage imageNamed:@"L2"].size.width]; + R2_X = [self xAvoidingDivisions:R2_X width:[UIImage imageNamed:@"R2"].size.width]; + L3_X = [self xAvoidingDivisions:L3_X width:[UIImage imageNamed:@"L3"].size.width]; + R3_X = [self xAvoidingDivisions:R3_X width:[UIImage imageNamed:@"R3"].size.width]; +} + - (void) show { _visible = YES; @@ -192,7 +301,8 @@ - (void) updateControls { // GCGamepad is missing triggers, both analog sticks, // and the select button [self setupGamepadControls]; - + [self avoidDivisionRegions]; + [self hideButtons]; [self hideBumpers]; [self hideL3R3]; @@ -203,7 +313,8 @@ - (void) updateControls { case OnScreenControlsLevelAutoGCExtendedGamepad: // GCExtendedGamepad is missing R3, L3, and select [self setupExtendedGamepadControls]; - + [self avoidDivisionRegions]; + [self hideButtons]; [self hideBumpers]; [self hideTriggers]; @@ -215,7 +326,8 @@ - (void) updateControls { // This variant of GCExtendedGamepad has L3 and R3 but // is still missing Select [self setupExtendedGamepadControls]; - + [self avoidDivisionRegions]; + [self hideButtons]; [self hideBumpers]; [self hideTriggers]; @@ -225,7 +337,8 @@ - (void) updateControls { break; case OnScreenControlsLevelSimple: [self setupSimpleControls]; - + [self avoidDivisionRegions]; + [self hideTriggers]; [self hideL3R3]; [self hideBumpers]; @@ -235,7 +348,8 @@ - (void) updateControls { break; case OnScreenControlsLevelFull: [self setupComplexControls]; - + [self avoidDivisionRegions]; + [self drawButtons]; [self drawStartSelect]; [self drawBumpers]; @@ -262,7 +376,7 @@ - (void) setupExtendedGamepadControls { L3_Y = _controlArea.size.height * .85 + _controlArea.origin.y; R3_Y = _controlArea.size.height * .85 + _controlArea.origin.y; - if (_iPad) { + if (_largeScreen) { L3_X = _controlArea.size.width * .15 + _controlArea.origin.x; R3_X = _controlArea.size.width * .85 + _controlArea.origin.x; } @@ -289,8 +403,8 @@ - (void) setupGamepadControls { SELECT_X = _controlArea.size.width * .05 + _controlArea.origin.x; SELECT_Y = _controlArea.size.height * .95 + _controlArea.origin.y; - if (_iPad) { - // The analog sticks are kept closer to the sides on iPad + if (_largeScreen) { + // The analog sticks are kept closer to the sides on large screens LS_CENTER_X = _controlArea.size.width * .15 + _controlArea.origin.x; RS_CENTER_X = _controlArea.size.width * .85 + _controlArea.origin.x; } @@ -310,8 +424,8 @@ - (void) setupSimpleControls { R2_Y = _controlArea.size.height * .9 + _controlArea.origin.y; R2_X = _controlArea.size.width * .9 + _controlArea.origin.x; - if (_iPad) { - // Lower the D-pad and buttons on iPad + if (_largeScreen) { + // Lower the D-pad and buttons on large screens D_PAD_CENTER_Y = _controlArea.size.height * .75 + _controlArea.origin.y; BUTTON_CENTER_Y = _controlArea.size.height * .75 + _controlArea.origin.y; @@ -332,9 +446,9 @@ - (void) setupComplexControls BUTTON_CENTER_X = _controlArea.size.width * .9 + _controlArea.origin.x; BUTTON_CENTER_Y = _controlArea.size.height * .60 + _controlArea.origin.y; - if (_iPad) + if (_largeScreen) { - // The analog sticks are kept closer to the sides on iPad + // The analog sticks are kept closer to the sides on large screens LS_CENTER_X = _controlArea.size.width * .22 + _controlArea.origin.x; LS_CENTER_Y = _controlArea.size.height * .80 + _controlArea.origin.y; RS_CENTER_X = _controlArea.size.width * .77 + _controlArea.origin.x; @@ -358,8 +472,8 @@ - (void) setupComplexControls R1_Y = _controlArea.size.height * .27 + _controlArea.origin.y; R2_Y = _controlArea.size.height * .1 + _controlArea.origin.y; - if (_iPad) { - // Move L/R buttons closer to the side on iPad + if (_largeScreen) { + // Move L/R buttons closer to the side on large screens L1_X = _controlArea.size.width * .05 + _controlArea.origin.x; L2_X = _controlArea.size.width * .05 + _controlArea.origin.x; R1_X = _controlArea.size.width * .95 + _controlArea.origin.x; diff --git a/Limelight/Input/StreamView.h b/Limelight/Input/StreamView.h index 9f500101a..86ccccf7a 100644 --- a/Limelight/Input/StreamView.h +++ b/Limelight/Input/StreamView.h @@ -29,6 +29,7 @@ config:(StreamConfiguration*)streamConfig; - (void) showOnScreenControls; - (OnScreenControlsLevel) getCurrentOscState; +- (CGSize) getVideoAreaSize; #if !TARGET_OS_TV - (void) updateCursorLocation:(CGPoint)location isMouse:(BOOL)isMouse; diff --git a/Limelight/Input/StreamView.m b/Limelight/Input/StreamView.m index 7dedeb35e..8daf24cb2 100644 --- a/Limelight/Input/StreamView.m +++ b/Limelight/Input/StreamView.m @@ -8,6 +8,7 @@ #import "StreamView.h" #include +#import #import "DataManager.h" #import "ControllerSupport.h" #import "KeyboardSupport.h" @@ -25,7 +26,8 @@ @implementation StreamView { NSMutableSet* keysDown; float streamAspectRatio; - + int videoScalingMode; + // iOS 13.4 mouse support NSInteger lastMouseButtonMask; float lastMouseX; @@ -40,6 +42,7 @@ @implementation StreamView { UIResponder* touchHandler; id interactionDelegate; + __weak ControllerSupport* controllerSupport; NSTimer* interactionTimer; BOOL hasUserInteracted; @@ -50,10 +53,15 @@ - (void) setupStreamView:(ControllerSupport*)controllerSupport interactionDelegate:(id)interactionDelegate config:(StreamConfiguration*)streamConfig { self->interactionDelegate = interactionDelegate; + self->controllerSupport = controllerSupport; self->streamAspectRatio = (float)streamConfig.width / (float)streamConfig.height; TemporarySettings* settings = [[[DataManager alloc] init] getSettings]; - + + // The video can extend past our bounds when it's scaled to fill them + self->videoScalingMode = settings.videoScalingMode; + self.clipsToBounds = (videoScalingMode == VIDEO_SCALING_FILL); + keysDown = [[NSMutableSet alloc] init]; keyInputField = [[KeyboardInputField alloc] initWithFrame:CGRectZero]; [keyInputField setKeyboardType:UIKeyboardTypeDefault]; @@ -177,11 +185,56 @@ - (OnScreenControlsLevel) getCurrentOscState { } - (CGSize) getVideoAreaSize { - if (self.bounds.size.width > self.bounds.size.height * streamAspectRatio) { - return CGSizeMake(self.bounds.size.height * streamAspectRatio, self.bounds.size.height); - } else { - return CGSizeMake(self.bounds.size.width, self.bounds.size.width / streamAspectRatio); + if (streamAspectRatio <= 0) { + // Not set up yet + return self.bounds.size; + } + + switch (videoScalingMode) { + case VIDEO_SCALING_STRETCH: + // Fill our bounds, distorting the video if the aspect ratios differ + return self.bounds.size; + + case VIDEO_SCALING_FILL: + // Fill our bounds while preserving the aspect ratio, cropping the edges + // of the video that don't fit (like the sides of a 16:9 stream on the + // inner display of iPhone Duo) + if (self.bounds.size.width > self.bounds.size.height * streamAspectRatio) { + return CGSizeMake(self.bounds.size.width, self.bounds.size.width / streamAspectRatio); + } else { + return CGSizeMake(self.bounds.size.height * streamAspectRatio, self.bounds.size.height); + } + + case VIDEO_SCALING_FIT: + default: + if (self.bounds.size.width > self.bounds.size.height * streamAspectRatio) { + return CGSizeMake(self.bounds.size.height * streamAspectRatio, self.bounds.size.height); + } else { + return CGSizeMake(self.bounds.size.width, self.bounds.size.width / streamAspectRatio); + } + } +} + +- (void)layoutSubviews { + [super layoutSubviews]; + + // Our size can change mid-stream (rotation, iPad multitasking, or folding and + // unfolding a foldable like iPhone Duo), so keep the video layer sized to the + // same video region that we use for mapping touch and mouse input. + CGSize videoSize = [self getVideoAreaSize]; + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + for (CALayer* layer in self.layer.sublayers) { + if ([layer isKindOfClass:[AVSampleBufferDisplayLayer class]]) { + layer.position = CGPointMake(CGRectGetMidX(self.bounds), CGRectGetMidY(self.bounds)); + layer.bounds = CGRectMake(0, 0, videoSize.width, videoSize.height); + } } + [CATransaction commit]; + +#if !TARGET_OS_TV + [onScreenControls updateLayout]; +#endif } - (CGPoint) adjustCoordinatesForVideoArea:(CGPoint)point { @@ -208,7 +261,7 @@ - (CGPoint) adjustCoordinatesForVideoArea:(CGPoint)point { y++; } - // This logic mimics what iOS does with AVLayerVideoGravityResizeAspect + // The video is centered in our bounds (see layoutSubviews) CGSize videoSize = [self getVideoAreaSize]; CGPoint videoOrigin = CGPointMake(self.bounds.size.width / 2 - videoSize.width / 2, self.bounds.size.height / 2 - videoSize.height / 2); @@ -453,7 +506,12 @@ - (BOOL)handleMouseButtonEvent:(int)buttonAction forTouches:(NSSet *)touches wit UITouch* touch = [touches anyObject]; if (touch.type == UITouchTypeIndirectPointer) { if (@available(iOS 14.0, *)) { - if ([GCMouse current] != nil) { + // iPhone only supports mice through AssistiveTouch, which doesn't always + // give us the mouse buttons through GCMouse. Use these button events until + // GCMouse delivers one, so each click isn't sent to the host twice. + if ([GCMouse current] != nil && + ([UIDevice currentDevice].userInterfaceIdiom != UIUserInterfaceIdiomPhone || + [controllerSupport hasReceivedMouseButtonEvents])) { // We'll handle this with GCMouse. Do nothing here. return YES; } @@ -615,6 +673,10 @@ - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { } - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { + // Release any on-screen controls held by these touches, so they don't + // keep a stick deflected (or get re-applied when the controls are laid + // out again) after the system cancels the touches + [onScreenControls handleTouchUpEvent:touches]; [touchHandler touchesCancelled:touches withEvent:event]; [self handleMouseButtonEvent:BUTTON_ACTION_RELEASE forTouches:touches @@ -659,30 +721,29 @@ - (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction defaultRegion:(UIPointerRegion *)defaultRegion API_AVAILABLE(ios(13.4)) { if (@available(iOS 14.0, *)) { if ([GCMouse current] != nil) { - // We'll handle this with GCMouse. Do nothing here. + // We'll handle this with GCMouse. On iPhone, the system pointer comes from + // AssistiveTouch, so keep it hidden over the stream (see styleForRegion) + // instead of showing it in addition to the host's cursor. + if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone) { + return [UIPointerRegion regionWithRect:self.bounds identifier:nil]; + } return nil; } } - // This logic mimics what iOS does with AVLayerVideoGravityResizeAspect - CGSize videoSize; - CGPoint videoOrigin; - if (self.bounds.size.width > self.bounds.size.height * streamAspectRatio) { - videoSize = CGSizeMake(self.bounds.size.height * streamAspectRatio, self.bounds.size.height); - } else { - videoSize = CGSizeMake(self.bounds.size.width, self.bounds.size.width / streamAspectRatio); - } - videoOrigin = CGPointMake(self.bounds.size.width / 2 - videoSize.width / 2, - self.bounds.size.height / 2 - videoSize.height / 2); - + // The video is centered in our bounds (see layoutSubviews) + CGSize videoSize = [self getVideoAreaSize]; + CGPoint videoOrigin = CGPointMake(self.bounds.size.width / 2 - videoSize.width / 2, + self.bounds.size.height / 2 - videoSize.height / 2); + // Move the cursor on the host if no buttons are pressed. // Motion with buttons pressed in handled in touchesMoved: if (lastMouseButtonMask == 0) { [self updateCursorLocation:request.location isMouse:YES]; } - // The pointer interaction should cover the video region only - return [UIPointerRegion regionWithRect:CGRectMake(videoOrigin.x, videoOrigin.y, videoSize.width, videoSize.height) identifier:nil]; + // The pointer interaction should cover the visible video region only + return [UIPointerRegion regionWithRect:CGRectIntersection(CGRectMake(videoOrigin.x, videoOrigin.y, videoSize.width, videoSize.height), self.bounds) identifier:nil]; } - (UIPointerStyle *)pointerInteraction:(UIPointerInteraction *)interaction styleForRegion:(UIPointerRegion *)region API_AVAILABLE(ios(13.4)) { diff --git a/Limelight/Limelight-Info.plist b/Limelight/Limelight-Info.plist index 963c433d5..2e33cb908 100644 --- a/Limelight/Limelight-Info.plist +++ b/Limelight/Limelight-Info.plist @@ -58,14 +58,48 @@ NSLocalNetworkUsageDescription Moonlight uses the local network to connect to your gaming PC for streaming. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + SceneDelegate + UISceneStoryboardFile + iPhone + + + + + UIApplicationSceneManifest~ipad + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + SceneDelegate + UISceneStoryboardFile + iPad + + + + UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName Launch Screen - UIMainStoryboardFile - iPhone - UIMainStoryboardFile~ipad - iPad UIRequiredDeviceCapabilities armv7 diff --git a/Limelight/Limelight.xcdatamodeld/.xccurrentversion b/Limelight/Limelight.xcdatamodeld/.xccurrentversion index b202eb9f3..cffa4ac22 100644 --- a/Limelight/Limelight.xcdatamodeld/.xccurrentversion +++ b/Limelight/Limelight.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Moonlight v1.10.xcdatamodel + Moonlight v1.11.xcdatamodel diff --git a/Limelight/Limelight.xcdatamodeld/Moonlight v1.11.xcdatamodel/contents b/Limelight/Limelight.xcdatamodeld/Moonlight v1.11.xcdatamodel/contents new file mode 100644 index 000000000..1dead8438 --- /dev/null +++ b/Limelight/Limelight.xcdatamodeld/Moonlight v1.11.xcdatamodel/contents @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Limelight/SceneDelegate.h b/Limelight/SceneDelegate.h new file mode 100644 index 000000000..50d28c9a3 --- /dev/null +++ b/Limelight/SceneDelegate.h @@ -0,0 +1,14 @@ +// +// SceneDelegate.h +// Moonlight +// +// Copyright (c) 2026 Moonlight Stream. All rights reserved. +// + +#import + +@interface SceneDelegate : UIResponder + +@property (strong, nonatomic) UIWindow *window; + +@end diff --git a/Limelight/SceneDelegate.m b/Limelight/SceneDelegate.m new file mode 100644 index 000000000..a68e53dec --- /dev/null +++ b/Limelight/SceneDelegate.m @@ -0,0 +1,32 @@ +// +// SceneDelegate.m +// Moonlight +// +// Copyright (c) 2026 Moonlight Stream. All rights reserved. +// + +#import "SceneDelegate.h" +#import "AppDelegate.h" + +@implementation SceneDelegate + +// Apps built with the iOS 27 SDK must adopt the UIScene life cycle or UIKit will +// terminate them at launch. UIKit creates our window from the storyboard named in +// the scene manifest in our Info.plist, so we only need to handle quick actions. + +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions { + // Handle being launched from a home screen quick action + UIApplicationShortcutItem* shortcut = connectionOptions.shortcutItem; + if (shortcut != nil) { + AppDelegate* appDelegate = (AppDelegate*)[UIApplication sharedApplication].delegate; + appDelegate.pcUuidToLoad = (NSString*)[shortcut.userInfo objectForKey:@"UUID"]; + } +} + +- (void)windowScene:(UIWindowScene *)windowScene performActionForShortcutItem:(UIApplicationShortcutItem *)shortcutItem completionHandler:(void (^)(BOOL succeeded))completionHandler { + AppDelegate* appDelegate = (AppDelegate*)[UIApplication sharedApplication].delegate; + appDelegate.pcUuidToLoad = (NSString*)[shortcutItem.userInfo objectForKey:@"UUID"]; + appDelegate.shortcutCompletionHandler = completionHandler; +} + +@end diff --git a/Limelight/Stream/StreamManager.m b/Limelight/Stream/StreamManager.m index e776feef0..68af5c679 100644 --- a/Limelight/Stream/StreamManager.m +++ b/Limelight/Stream/StreamManager.m @@ -99,7 +99,7 @@ - (void)main { // Initializing the renderer must be done on the main thread dispatch_async(dispatch_get_main_queue(), ^{ - VideoDecoderRenderer* renderer = [[VideoDecoderRenderer alloc] initWithView:self->_renderView callbacks:self->_callbacks streamAspectRatio:(float)self->_config.width / (float)self->_config.height useFramePacing:self->_config.useFramePacing]; + VideoDecoderRenderer* renderer = [[VideoDecoderRenderer alloc] initWithView:self->_renderView callbacks:self->_callbacks useFramePacing:self->_config.useFramePacing]; self->_connection = [[Connection alloc] initWithConfig:self->_config renderer:renderer connectionCallbacks:self->_callbacks]; NSOperationQueue* opQueue = [[NSOperationQueue alloc] init]; [opQueue addOperation:self->_connection]; diff --git a/Limelight/Stream/VideoDecoderRenderer.h b/Limelight/Stream/VideoDecoderRenderer.h index 0513374b0..24c318185 100644 --- a/Limelight/Stream/VideoDecoderRenderer.h +++ b/Limelight/Stream/VideoDecoderRenderer.h @@ -14,7 +14,7 @@ @interface VideoDecoderRenderer : NSObject -- (id)initWithView:(UIView*)view callbacks:(id)callbacks streamAspectRatio:(float)aspectRatio useFramePacing:(BOOL)useFramePacing; +- (id)initWithView:(UIView*)view callbacks:(id)callbacks useFramePacing:(BOOL)useFramePacing; - (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int)videoHeight frameRate:(int)frameRate; - (void)start; diff --git a/Limelight/Stream/VideoDecoderRenderer.m b/Limelight/Stream/VideoDecoderRenderer.m index 5c89e5ade..f63c6cda5 100644 --- a/Limelight/Stream/VideoDecoderRenderer.m +++ b/Limelight/Stream/VideoDecoderRenderer.m @@ -22,8 +22,7 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size, @implementation VideoDecoderRenderer { StreamView* _view; id _callbacks; - float _streamAspectRatio; - + AVSampleBufferDisplayLayer* displayLayer; int videoFormat; int frameRate; @@ -44,17 +43,14 @@ - (void)reinitializeDisplayLayer displayLayer = [[AVSampleBufferDisplayLayer alloc] init]; displayLayer.backgroundColor = [UIColor blackColor].CGColor; - // Ensure the AVSampleBufferDisplayLayer is sized to preserve the aspect ratio - // of the video stream. We used to use AVLayerVideoGravityResizeAspect, but that - // respects the PAR encoded in the SPS which causes our computed video-relative - // touch location to be wrong in StreamView if the aspect ratio of the host - // desktop doesn't match the aspect ratio of the stream. - CGSize videoSize; - if (_view.bounds.size.width > _view.bounds.size.height * _streamAspectRatio) { - videoSize = CGSizeMake(_view.bounds.size.height * _streamAspectRatio, _view.bounds.size.height); - } else { - videoSize = CGSizeMake(_view.bounds.size.width, _view.bounds.size.width / _streamAspectRatio); - } + // Ensure the AVSampleBufferDisplayLayer is sized to the video region that + // StreamView uses for computing video-relative touch locations, which also + // honors the user's video scaling preference. We used to use + // AVLayerVideoGravityResizeAspect, but that respects the PAR encoded in the + // SPS which causes our computed video-relative touch location to be wrong in + // StreamView if the aspect ratio of the host desktop doesn't match the aspect + // ratio of the stream. + CGSize videoSize = [_view getVideoAreaSize]; displayLayer.position = CGPointMake(CGRectGetMidX(_view.bounds), CGRectGetMidY(_view.bounds)); displayLayer.bounds = CGRectMake(0, 0, videoSize.width, videoSize.height); displayLayer.videoGravity = AVLayerVideoGravityResize; @@ -77,13 +73,12 @@ - (void)reinitializeDisplayLayer } } -- (id)initWithView:(StreamView*)view callbacks:(id)callbacks streamAspectRatio:(float)aspectRatio useFramePacing:(BOOL)useFramePacing +- (id)initWithView:(StreamView*)view callbacks:(id)callbacks useFramePacing:(BOOL)useFramePacing { self = [super init]; _view = view; _callbacks = callbacks; - _streamAspectRatio = aspectRatio; framePacing = useFramePacing; parameterSetBuffers = [[NSMutableArray alloc] init]; @@ -99,16 +94,39 @@ - (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int) self->frameRate = frameRate; } -- (void)start +- (CADisplayLink*)createDisplayLink { - _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkCallback:)]; - if (@available(iOS 15.0, tvOS 15.0, *)) { - _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(self->frameRate, self->frameRate, self->frameRate); - } - else { - _displayLink.preferredFramesPerSecond = self->frameRate; +#if defined(__IPHONE_27_0) || defined(__TVOS_27_0) + if (@available(iOS 27.0, tvOS 27.0, *)) { + // Prefer a display link from our window scene, which is retargeted if the scene + // moves to another display (like when an iPhone Duo is folded or unfolded) + UIWindowScene* windowScene = _view.window.windowScene; + if (windowScene != nil) { + CADisplayLink* displayLink = [windowScene displayLinkWithTarget:self selector:@selector(displayLinkCallback:)]; + if (displayLink != nil) { + return displayLink; + } + } } - [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode]; +#endif + return [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkCallback:)]; +} + +- (void)start +{ + // We're called on a thread inside common, but we need the main thread to get our + // window scene. The display link fires on the main thread, so this also ensures we + // don't race with stop, which is always called after start. + dispatch_async(dispatch_get_main_queue(), ^{ + self->_displayLink = [self createDisplayLink]; + if (@available(iOS 15.0, tvOS 15.0, *)) { + self->_displayLink.preferredFrameRateRange = CAFrameRateRangeMake(self->frameRate, self->frameRate, self->frameRate); + } + else { + self->_displayLink.preferredFramesPerSecond = self->frameRate; + } + [self->_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode]; + }); } // TODO: Refactor this @@ -142,7 +160,17 @@ - (void)displayLinkCallback:(CADisplayLink *)sender - (void)stop { - [_displayLink invalidate]; + // Invalidate the display link on the main thread where it is created and fires. + // This also ensures no callback can run after we return. LiStopConnection() is + // always called on a background queue, so the main thread is never waiting on us. + if ([NSThread isMainThread]) { + [_displayLink invalidate]; + } + else { + dispatch_sync(dispatch_get_main_queue(), ^{ + [self->_displayLink invalidate]; + }); + } } #define NALU_START_PREFIX_SIZE 3 diff --git a/Limelight/Utility/Utils.h b/Limelight/Utility/Utils.h index 1616bf922..9dd5415a9 100644 --- a/Limelight/Utility/Utils.h +++ b/Limelight/Utility/Utils.h @@ -31,6 +31,7 @@ FOUNDATION_EXPORT NSString *const deviceName; + (NSString*) addressPortStringToAddress:(NSString*)addressPort; + (unsigned short) addressPortStringToPort:(NSString*)addressPort; + (NSString*) addressAndPortToAddressPortString:(NSString*)address port:(unsigned short)port; ++ (CGSize) getDisplayResolutionForWindow:(UIWindow*)window safeArea:(BOOL)safeArea; #if !TARGET_OS_TV + (void) launchUrl:(NSString*)urlString; diff --git a/Limelight/Utility/Utils.m b/Limelight/Utility/Utils.m index fded11b77..4485d67ab 100644 --- a/Limelight/Utility/Utils.m +++ b/Limelight/Utility/Utils.m @@ -146,6 +146,32 @@ + (NSString*) addressAndPortToAddressPortString:(NSString*)address port:(unsigne } } ++ (CGSize) getDisplayResolutionForWindow:(UIWindow*)window safeArea:(BOOL)safeArea { + if (window == nil) { + // The caller may not be in a window yet, so use the first one we can find + for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + window = ((UIWindowScene*)scene).windows.firstObject; + break; + } + } + } + + CGFloat width = window.bounds.size.width; + if (safeArea) { + // The video is centered, so leave room for the larger inset on both sides + // (the insets are one-sided on displays like those of iPhone Duo) + width -= 2 * MAX(window.safeAreaInsets.left, window.safeAreaInsets.right); + } + + // Use the native scale rather than the UI scale, so we match the physical pixels + // of displays that downsample the UI (like the inner display of iPhone Duo, which + // renders at 3x but has a 2670x1878 panel). Video encoders require even dimensions. + CGFloat nativeScale = (window.windowScene.screen ?: window.screen).nativeScale; + return CGSizeMake(lround(width * nativeScale) & ~1, + lround(window.bounds.size.height * nativeScale) & ~1); +} + @end @implementation NSString (NSStringWithTrim) diff --git a/Limelight/ViewControllers/LoadingFrameViewController.m b/Limelight/ViewControllers/LoadingFrameViewController.m index 550ea60ac..7eb95c036 100644 --- a/Limelight/ViewControllers/LoadingFrameViewController.m +++ b/Limelight/ViewControllers/LoadingFrameViewController.m @@ -18,9 +18,25 @@ - (void)viewDidLoad { self.loadingSpinner.center = CGPointMake(self.view.frame.size.width / 2, self.view.frame.size.height / 2); } +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + // keep the loading spinner centered if our size changes + self.loadingSpinner.center = CGPointMake(self.view.bounds.size.width / 2, self.view.bounds.size.height / 2); +} + - (UIViewController*) activeViewController { - UIViewController *topController = [UIApplication sharedApplication].keyWindow.rootViewController; - + UIWindow *window = [UIApplication sharedApplication].keyWindow; + if (window == nil) { + // There may not be a key window yet if we're shown during launch + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + window = ((UIWindowScene*)scene).windows.firstObject; + break; + } + } + } + UIViewController *topController = window.rootViewController; + while (topController.presentedViewController) { topController = topController.presentedViewController; } diff --git a/Limelight/ViewControllers/MainFrameViewController.m b/Limelight/ViewControllers/MainFrameViewController.m index 169bb13ba..b862a7bd0 100644 --- a/Limelight/ViewControllers/MainFrameViewController.m +++ b/Limelight/ViewControllers/MainFrameViewController.m @@ -53,6 +53,7 @@ @implementation MainFrameViewController { NSArray* _sortedAppList; NSCache* _boxArtCache; bool _background; + BOOL _largeAppTiles; #if TARGET_OS_TV UITapGestureRecognizer* _menuRecognizer; #endif @@ -436,7 +437,9 @@ - (void) hostClicked:(TemporaryHost *)host view:(UIView *)view { } - (UIViewController*) activeViewController { - UIViewController *topController = [UIApplication sharedApplication].keyWindow.rootViewController; + // Prefer our own window, since there may not be a key window yet during launch + UIWindow *window = self.view.window ?: [UIApplication sharedApplication].keyWindow; + UIViewController *topController = window.rootViewController; while (topController.presentedViewController) { topController = topController.presentedViewController; @@ -614,15 +617,28 @@ - (void) prepareToStreamApp:(TemporaryApp *)app { _streamConfig.frameRate = [streamSettings.framerate intValue]; if (@available(iOS 10.3, *)) { - // Don't stream more FPS than the display can show - if (_streamConfig.frameRate > [UIScreen mainScreen].maximumFramesPerSecond) { - _streamConfig.frameRate = (int)[UIScreen mainScreen].maximumFramesPerSecond; + // Don't stream more FPS than the display can show. The main screen isn't + // necessarily the one we're on (like with the inner display of iPhone Duo). + UIScreen* screen = self.view.window.windowScene.screen ?: [UIScreen mainScreen]; + if (_streamConfig.frameRate > screen.maximumFramesPerSecond) { + _streamConfig.frameRate = (int)screen.maximumFramesPerSecond; Log(LOG_W, @"Clamping FPS to maximum refresh rate: %d", _streamConfig.frameRate); } } - _streamConfig.height = [streamSettings.height intValue]; - _streamConfig.width = [streamSettings.width intValue]; + if ([streamSettings.width intValue] == RESOLUTION_MATCH_DISPLAY_FULL || + [streamSettings.width intValue] == RESOLUTION_MATCH_DISPLAY_SAFE_AREA) { + // Match the display we're about to stream on + CGSize displayResolution = [Utils getDisplayResolutionForWindow:self.view.window + safeArea:[streamSettings.width intValue] == RESOLUTION_MATCH_DISPLAY_SAFE_AREA]; + _streamConfig.width = displayResolution.width; + _streamConfig.height = displayResolution.height; + Log(LOG_I, @"Matching display resolution: %dx%d", _streamConfig.width, _streamConfig.height); + } + else { + _streamConfig.height = [streamSettings.height intValue]; + _streamConfig.width = [streamSettings.width intValue]; + } #if TARGET_OS_TV // Don't allow streaming 4K on the Apple TV HD struct utsname systemInfo; @@ -919,6 +935,46 @@ - (void)viewSafeAreaInsetsDidChange { [self adjustScrollViewForSafeArea:self->hostScrollView]; } +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + + // Our view can change size (like when an iPhone Duo is folded or unfolded), + // so keep the host list sized to it and lay the hosts out again if it changes. + CGRect hostFrame = CGRectMake(0, self.navigationController.navigationBar.frame.origin.y + self.navigationController.navigationBar.frame.size.height, self.view.frame.size.width, self.view.frame.size.height / 2); + if (hostScrollView != nil && !CGSizeEqualToSize(hostScrollView.frame.size, hostFrame.size)) { + hostScrollView.frame = hostFrame; + [self updateHosts]; + } + +#if !TARGET_OS_TV + // The app tiles are scaled when their cells are configured, so reload them + // if a display change means we now need a different tile size. + BOOL largeAppTiles = [self shouldUseLargeAppTiles]; + if (largeAppTiles != _largeAppTiles) { + _largeAppTiles = largeAppTiles; + [self.collectionView reloadData]; + } +#endif +} + +#if !TARGET_OS_TV +- (BOOL)shouldUseLargeAppTiles { + // Use iPad-sized tiles on displays that are regular-sized in both dimensions, like + // the inner display of iPhone Duo, rather than phone-sized tiles. + return self.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular && + self.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassRegular; +} + +- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { + CGSize itemSize = ((UICollectionViewFlowLayout*)collectionViewLayout).itemSize; + if ([self shouldUseLargeAppTiles] && itemSize.width < 150) { + // Same size as the iPad storyboard's tiles and our app views + return CGSizeMake(150, 200); + } + return itemSize; +} +#endif + - (void)viewDidLoad { [super viewDidLoad]; @@ -942,6 +998,11 @@ - (void)viewDidLoad // Disable bounce-back on reveal VC otherwise the settings will snap closed // if the user drags all the way off the screen opposite the settings pane. self.revealViewController.bounceBackOnOverdraw = NO; + + // The navigation bar can sit below a reserved area at the top of the screen even + // with the status bar hidden (like on iPhone Duo), so fill that area with the bar + // color instead of leaving a black band above the bar. + self.navigationController.view.backgroundColor = self.navigationController.navigationBar.barTintColor; #else // The settings button will direct the user into the Settings app on tvOS [_settingsButton setTarget:self]; diff --git a/Limelight/ViewControllers/SWRevealViewController.m b/Limelight/ViewControllers/SWRevealViewController.m index bf648031f..5463e5360 100755 --- a/Limelight/ViewControllers/SWRevealViewController.m +++ b/Limelight/ViewControllers/SWRevealViewController.m @@ -1393,6 +1393,11 @@ - (void)setPrimaryViewController:(UIViewController*)viewController if (@available(iOS 14.0, *)) { [self setNeedsUpdateOfPrefersPointerLocked]; } +#if defined(__IPHONE_27_1) + if (@available(iOS 27.1, *)) { + [self setNeedsUpdateOfVerticalBarConfiguration]; + } +#endif } - (UIViewController*)childViewControllerForHomeIndicatorAutoHidden @@ -1410,6 +1415,13 @@ - (UIViewController*)childViewControllerForPointerLock return _primaryViewController; } +#if defined(__IPHONE_27_1) +- (UIViewController*)childViewControllerForPreferredVerticalBarBehavior +{ + return _primaryViewController; +} +#endif + #pragma mark Animated view controller deployment and layout // Primitive method for view controller deployment and animated layout to the given position. diff --git a/Limelight/ViewControllers/SettingsViewController.h b/Limelight/ViewControllers/SettingsViewController.h index 1a79162da..147700a49 100644 --- a/Limelight/ViewControllers/SettingsViewController.h +++ b/Limelight/ViewControllers/SettingsViewController.h @@ -15,6 +15,7 @@ @property (strong, nonatomic) IBOutlet UISegmentedControl *framerateSelector; @property (strong, nonatomic) IBOutlet UISegmentedControl *resolutionSelector; @property (strong, nonatomic) IBOutlet UIView *resolutionDisplayView; +@property (strong, nonatomic) IBOutlet UISegmentedControl *videoScalingSelector; @property (strong, nonatomic) IBOutlet UISegmentedControl *touchModeSelector; @property (strong, nonatomic) IBOutlet UISegmentedControl *onscreenControlSelector; @property (strong, nonatomic) IBOutlet UISegmentedControl *optimizeSettingsSelector; diff --git a/Limelight/ViewControllers/SettingsViewController.m b/Limelight/ViewControllers/SettingsViewController.m index bbe8eb2de..c789384c2 100644 --- a/Limelight/ViewControllers/SettingsViewController.m +++ b/Limelight/ViewControllers/SettingsViewController.m @@ -9,6 +9,7 @@ #import "SettingsViewController.h" #import "TemporarySettings.h" #import "DataManager.h" +#import "Utils.h" #import #import @@ -16,6 +17,7 @@ @implementation SettingsViewController { NSInteger _bitrate; NSInteger _lastSelectedResolutionIndex; + BOOL _safeAreaPadded; } @dynamic overrideUserInterfaceStyle; @@ -51,8 +53,11 @@ @implementation SettingsViewController { }; const int RESOLUTION_TABLE_SIZE = 7; +const int RESOLUTION_TABLE_SAFE_AREA_INDEX = 4; +const int RESOLUTION_TABLE_FULL_INDEX = 5; const int RESOLUTION_TABLE_CUSTOM_INDEX = RESOLUTION_TABLE_SIZE - 1; CGSize resolutionTable[RESOLUTION_TABLE_SIZE]; +CGSize resolutionTableWindowSize; -(int)getSliderValueForBitrate:(NSInteger)bitrate { int i; @@ -67,9 +72,32 @@ -(int)getSliderValueForBitrate:(NSInteger)bitrate { return i - 1; } +// The safe area and full screen resolutions depend on the display we're +// shown on, which can change while we're alive (like when an iPhone Duo is +// folded or unfolded), so we recompute them whenever our window size changes. +- (void)updateDisplayResolutions { + UIWindow *window = self.view.window ?: UIApplication.sharedApplication.windows.firstObject; + if (window == nil || CGSizeEqualToSize(window.bounds.size, resolutionTableWindowSize)) { + return; + } + resolutionTableWindowSize = window.bounds.size; + + resolutionTable[RESOLUTION_TABLE_SAFE_AREA_INDEX] = [Utils getDisplayResolutionForWindow:window safeArea:YES]; + resolutionTable[RESOLUTION_TABLE_FULL_INDEX] = [Utils getDisplayResolutionForWindow:window safeArea:NO]; +} + // This view is rooted at a ScrollView. To make it scrollable, // we'll update content size here. -(void)viewDidLayoutSubviews { + NSInteger selectedResolution = [self.resolutionSelector selectedSegmentIndex]; + BOOL lastSegmentSelected = selectedResolution + 1 == [self.resolutionSelector numberOfSegments]; + CGSize oldWindowSize = resolutionTableWindowSize; + [self updateDisplayResolutions]; + if (!CGSizeEqualToSize(oldWindowSize, resolutionTableWindowSize) && !lastSegmentSelected && + (selectedResolution == RESOLUTION_TABLE_SAFE_AREA_INDEX || selectedResolution == RESOLUTION_TABLE_FULL_INDEX)) { + [self updateResolutionDisplayViewText]; + } + CGFloat highestViewY = 0; // Enumerate the scroll view's subviews looking for the @@ -101,23 +129,29 @@ - (void)viewSafeAreaInsetsDidChange { [super viewSafeAreaInsetsDidChange]; if (@available(iOS 11.0, *)) { - for (UIView* view in self.view.subviews) { - // HACK: The official safe area is much too large for our purposes - // so we'll just use the presence of any safe area to indicate we should - // pad by 20. - if (self.view.safeAreaInsets.left >= 20 || self.view.safeAreaInsets.right >= 20) { - view.frame = CGRectMake(view.frame.origin.x + 20, view.frame.origin.y, view.frame.size.width, view.frame.size.height); + // HACK: The official safe area is much too large for our purposes + // so we'll just use the presence of any safe area to indicate we should + // pad by 20. The safe area can change many times (like when an iPhone Duo + // is folded or unfolded), so only pad once and remove it when not needed. + BOOL needsPadding = self.view.safeAreaInsets.left >= 20 || self.view.safeAreaInsets.right >= 20; + if (needsPadding != _safeAreaPadded) { + for (UIView* view in self.view.subviews) { + view.frame = CGRectOffset(view.frame, needsPadding ? 20 : -20, 0); } + _safeAreaPadded = needsPadding; } } } BOOL isCustomResolution(CGSize res) { - if (res.width == 0 && res.height == 0) { + // Also excludes the special display-matching values, which are negative + if (res.width <= 0 || res.height <= 0) { return NO; } - - for (int i = 0; i < RESOLUTION_TABLE_CUSTOM_INDEX; i++) { + + // Only compare against the fixed resolutions, since a custom resolution + // can match the display-dependent ones on some displays but not others + for (int i = 0; i < RESOLUTION_TABLE_SAFE_AREA_INDEX; i++) { if (res.width == resolutionTable[i].width && res.height == resolutionTable[i].height) { return NO; } @@ -126,6 +160,19 @@ BOOL isCustomResolution(CGSize res) { return YES; } +// Older versions saved the safe area and full screen resolutions as the size of +// the display they were chosen on, computed from the window size and UI scale. +- (BOOL)isLegacyDisplayResolution:(CGSize)res safeArea:(BOOL)safeArea { + UIWindow *window = self.view.window ?: UIApplication.sharedApplication.windows.firstObject; + CGFloat width = window.frame.size.width; + if (safeArea) { + width -= window.safeAreaInsets.left + window.safeAreaInsets.right; + } + return window != nil && + (NSInteger)res.width == (NSInteger)(width * window.screen.scale) && + (NSInteger)res.height == (NSInteger)(window.frame.size.height * window.screen.scale); +} + - (void)viewDidLoad { [super viewDidLoad]; @@ -140,13 +187,6 @@ - (void)viewDidLoad { // Ensure we pick a bitrate that falls exactly onto a slider notch _bitrate = bitrateTable[[self getSliderValueForBitrate:[currentSettings.bitrate intValue]]]; - // Get the size of the screen with and without safe area insets - UIWindow *window = UIApplication.sharedApplication.windows.firstObject; - CGFloat screenScale = window.screen.scale; - CGFloat safeAreaWidth = (window.frame.size.width - window.safeAreaInsets.left - window.safeAreaInsets.right) * screenScale; - CGFloat fullScreenWidth = window.frame.size.width * screenScale; - CGFloat fullScreenHeight = window.frame.size.height * screenScale; - self.resolutionDisplayView.layer.cornerRadius = 10; self.resolutionDisplayView.clipsToBounds = YES; UITapGestureRecognizer *resolutionDisplayViewTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(resolutionDisplayViewTapped:)]; @@ -156,12 +196,16 @@ - (void)viewDidLoad { resolutionTable[1] = CGSizeMake(1280, 720); resolutionTable[2] = CGSizeMake(1920, 1080); resolutionTable[3] = CGSizeMake(3840, 2160); - resolutionTable[4] = CGSizeMake(safeAreaWidth, fullScreenHeight); - resolutionTable[5] = CGSizeMake(fullScreenWidth, fullScreenHeight); + [self updateDisplayResolutions]; // safe area and full screen resolutionTable[6] = CGSizeMake([currentSettings.width integerValue], [currentSettings.height integerValue]); // custom initial value - + + // Treat display sizes saved by older versions as the display-matching options + // they were chosen from, so they're saved as such and follow the display + BOOL legacyFull = isCustomResolution(resolutionTable[6]) && [self isLegacyDisplayResolution:resolutionTable[6] safeArea:NO]; + BOOL legacySafeArea = isCustomResolution(resolutionTable[6]) && !legacyFull && [self isLegacyDisplayResolution:resolutionTable[6] safeArea:YES]; + // Don't populate the custom entry unless we have a custom resolution - if (!isCustomResolution(resolutionTable[6])) { + if (!isCustomResolution(resolutionTable[6]) || legacyFull || legacySafeArea) { resolutionTable[6] = CGSizeMake(0, 0); } @@ -180,11 +224,22 @@ - (void)viewDidLoad { } NSInteger resolution = 1; - for (int i = 0; i < RESOLUTION_TABLE_SIZE; i++) { - if ((int) resolutionTable[i].height == [currentSettings.height intValue] - && (int) resolutionTable[i].width == [currentSettings.width intValue]) { - resolution = i; - break; + if ([currentSettings.width intValue] == RESOLUTION_MATCH_DISPLAY_SAFE_AREA || legacySafeArea) { + resolution = RESOLUTION_TABLE_SAFE_AREA_INDEX; + } + else if ([currentSettings.width intValue] == RESOLUTION_MATCH_DISPLAY_FULL || legacyFull) { + resolution = RESOLUTION_TABLE_FULL_INDEX; + } + else if (isCustomResolution(resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX])) { + resolution = RESOLUTION_TABLE_CUSTOM_INDEX; + } + else { + for (int i = 0; i < RESOLUTION_TABLE_SAFE_AREA_INDEX; i++) { + if ((int) resolutionTable[i].height == [currentSettings.height intValue] + && (int) resolutionTable[i].width == [currentSettings.width intValue]) { + resolution = i; + break; + } } } @@ -240,6 +295,7 @@ - (void)viewDidLoad { [self.hdrSelector setSelectedSegmentIndex:currentSettings.enableHdr ? 1 : 0]; } + [self.videoScalingSelector setSelectedSegmentIndex:currentSettings.videoScalingMode]; [self.touchModeSelector setSelectedSegmentIndex:currentSettings.absoluteTouchMode ? 1 : 0]; [self.touchModeSelector addTarget:self action:@selector(touchModeChanged) forControlEvents:UIControlEventValueChanged]; [self.statsOverlaySelector setSelectedSegmentIndex:currentSettings.statsOverlay ? 1 : 0]; @@ -405,6 +461,10 @@ - (void) promptCustomResolutionDialog { width = MAX(width, 256); height = MAX(height, 256); + // Video encoders require even dimensions + width &= ~1; + height &= ~1; + resolutionTable[RESOLUTION_TABLE_CUSTOM_INDEX] = CGSizeMake(width, height); [self updateBitrate]; [self updateResolutionDisplayViewText]; @@ -527,6 +587,18 @@ - (void) saveSettings { NSInteger framerate = [self getChosenFrameRate]; NSInteger height = [self getChosenStreamHeight]; NSInteger width = [self getChosenStreamWidth]; + + // Save the safe area and full screen options as special values, so they will + // match whichever display the stream is started on rather than this one + NSInteger selectedResolution = [self.resolutionSelector selectedSegmentIndex]; + BOOL lastSegmentSelected = selectedResolution + 1 == [self.resolutionSelector numberOfSegments]; + if (!lastSegmentSelected && selectedResolution == RESOLUTION_TABLE_SAFE_AREA_INDEX) { + width = height = RESOLUTION_MATCH_DISPLAY_SAFE_AREA; + } + else if (!lastSegmentSelected && selectedResolution == RESOLUTION_TABLE_FULL_INDEX) { + width = height = RESOLUTION_MATCH_DISPLAY_FULL; + } + NSInteger onscreenControls = [self.onscreenControlSelector selectedSegmentIndex]; BOOL optimizeGames = [self.optimizeSettingsSelector selectedSegmentIndex] == 1; BOOL multiController = [self.multiControllerSelector selectedSegmentIndex] == 1; @@ -538,6 +610,7 @@ - (void) saveSettings { BOOL absoluteTouchMode = [self.touchModeSelector selectedSegmentIndex] == 1; BOOL statsOverlay = [self.statsOverlaySelector selectedSegmentIndex] == 1; BOOL enableHdr = [self.hdrSelector selectedSegmentIndex] == 1; + uint32_t videoScalingMode = (uint32_t)[self.videoScalingSelector selectedSegmentIndex]; [dataMan saveSettingsWithBitrate:_bitrate framerate:framerate height:height @@ -553,7 +626,8 @@ - (void) saveSettings { enableHdr:enableHdr btMouseSupport:btMouseSupport absoluteTouchMode:absoluteTouchMode - statsOverlay:statsOverlay]; + statsOverlay:statsOverlay + videoScalingMode:videoScalingMode]; } - (void)didReceiveMemoryWarning { diff --git a/Limelight/ViewControllers/StreamFrameViewController.m b/Limelight/ViewControllers/StreamFrameViewController.m index a4c8f3448..eb994ae97 100644 --- a/Limelight/ViewControllers/StreamFrameViewController.m +++ b/Limelight/ViewControllers/StreamFrameViewController.m @@ -30,6 +30,13 @@ @interface AVDisplayCriteria() - (id)initWithRefreshRate:(float)arg1 videoDynamicRange:(int)arg2; @end +#if !TARGET_OS_TV +// How far a swipe from the left edge must go to end the stream. Taps and short +// drags near the edge (like on a dock at the side of the host's desktop) stay +// well under this, while a deliberate swipe easily covers it. +static const CGFloat MIN_EXIT_SWIPE_DISTANCE = 100; +#endif + @implementation StreamFrameViewController { ControllerSupport *_controllerSupport; StreamManager *_streamMan; @@ -129,8 +136,13 @@ - (void)viewDidLoad [self.view addGestureRecognizer:_playPauseTapGestureRecognizer]; #else - _exitSwipeRecognizer = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(edgeSwiped)]; + _exitSwipeRecognizer = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(edgeSwiped:)]; _exitSwipeRecognizer.edges = UIRectEdgeLeft; + + // Only a finger can swipe to end the stream, so dragging a mouse + // along the edge of the screen won't end it + _exitSwipeRecognizer.allowedTouchTypes = @[@(UITouchTypeDirect)]; + _exitSwipeRecognizer.delaysTouchesBegan = NO; _exitSwipeRecognizer.delaysTouchesEnded = NO; @@ -211,6 +223,33 @@ - (void)viewDidLoad [self.view addSubview:_tipLabel]; } +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + + // Our view can change size mid-stream (rotation, iPad multitasking, or + // folding and unfolding a foldable like iPhone Duo), so keep the stream + // filling it. The StreamView will adapt the video and on-screen controls. + CGRect bounds = self.view.bounds; + if (_scrollView != nil) { + if (!CGRectEqualToRect(_scrollView.frame, bounds)) { + [_scrollView setZoomScale:1.0f animated:NO]; + _scrollView.frame = bounds; + _streamView.frame = bounds; + _scrollView.contentSize = bounds.size; + } + } + else if (!CGRectEqualToRect(_streamView.frame, bounds)) { + _streamView.frame = bounds; + } + + _stageLabel.center = CGPointMake(bounds.size.width / 2, bounds.size.height / 2); + _spinner.center = CGPointMake(bounds.size.width / 2, bounds.size.height / 2 - _stageLabel.frame.size.height - _spinner.frame.size.height); + _tipLabel.center = CGPointMake(bounds.size.width / 2, bounds.size.height * 0.9); + if (_overlayView != nil) { + _overlayView.center = CGPointMake(bounds.size.width / 2, _overlayView.frame.size.height / 2); + } +} + - (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView { return _streamView; } @@ -361,11 +400,21 @@ - (void)applicationDidEnterBackground:(UIApplication *)application { [self returnToMainFrame]; } -- (void)edgeSwiped { +#if !TARGET_OS_TV +- (void)edgeSwiped:(UIScreenEdgePanGestureRecognizer*)gesture { + // Wait for a deliberate swipe, so a tap or short drag near the edge of + // the screen (like on a dock at the side of the host's desktop) doesn't + // end the stream + if (gesture.state != UIGestureRecognizerStateEnded || + [gesture translationInView:self.view].x < MIN_EXIT_SWIPE_DISTANCE) { + return; + } + Log(LOG_I, @"User swiped to end stream"); [self returnToMainFrame]; } +#endif - (void) connectionStarted { Log(LOG_I, @"Connection started"); @@ -703,6 +752,14 @@ - (BOOL)shouldAutorotate { return YES; } +#if defined(__IPHONE_27_1) && !TARGET_OS_TV +- (UIVerticalBarBehavior)preferredVerticalBarBehavior API_AVAILABLE(ios(27.1)) { + // The stream is fullscreen video, so keep the vertical bar on + // displays like the inner display of iPhone Duo out of the way + return UIVerticalBarBehaviorDisabled; +} +#endif + - (BOOL)prefersPointerLocked { // Pointer lock breaks the UIKit mouse APIs, which is a problem because // GCMouse is horribly broken on iOS 14.0 for certain mice. Only lock diff --git a/Moonlight.xcodeproj/project.pbxproj b/Moonlight.xcodeproj/project.pbxproj index e686ec129..8af8ccbe4 100644 --- a/Moonlight.xcodeproj/project.pbxproj +++ b/Moonlight.xcodeproj/project.pbxproj @@ -96,6 +96,7 @@ FB290CFE19B2C406004C83CF /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = FB290CFC19B2C406004C83CF /* InfoPlist.strings */; }; FB290D0019B2C406004C83CF /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = FB290CFF19B2C406004C83CF /* main.m */; }; FB290D0419B2C406004C83CF /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = FB290D0319B2C406004C83CF /* AppDelegate.m */; }; + 373DBA3BACAB43B6ACB1140E /* SceneDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 82AC1379506ECCBE4DFD56B7 /* SceneDelegate.m */; }; FB290D0719B2C406004C83CF /* Limelight.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = FB290D0519B2C406004C83CF /* Limelight.xcdatamodeld */; }; FB290D0919B2C406004C83CF /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FB290D0819B2C406004C83CF /* Images.xcassets */; }; FB290E7919B37D81004C83CF /* iPad.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FB290E7819B37D81004C83CF /* iPad.storyboard */; }; @@ -193,6 +194,7 @@ 986CCE6C2133E45300168291 /* Moonlight v1.2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.2.xcdatamodel"; sourceTree = ""; }; 986E28A528EA989100758361 /* Moonlight v1.9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.9.xcdatamodel"; sourceTree = ""; }; 9874E8962AE95E1D00130A3C /* Moonlight v1.10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.10.xcdatamodel"; sourceTree = ""; }; + 6F1029036A2BD5FD8A00A1A0 /* Moonlight v1.11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.11.xcdatamodel"; sourceTree = ""; }; 98783FEA242EAC5D00F00EF4 /* Moonlight v1.5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Moonlight v1.5.xcdatamodel"; sourceTree = ""; }; 98882A032AF60F5300C5A11C /* libavcodec.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libavcodec.a; path = libs/FFmpeg/lib/iOS/libavcodec.a; sourceTree = ""; }; 98882A042AF60F5300C5A11C /* libavutil.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libavutil.a; path = libs/FFmpeg/lib/iOS/libavutil.a; sourceTree = ""; }; @@ -239,6 +241,8 @@ FB290D0119B2C406004C83CF /* Limelight-Prefix.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Limelight-Prefix.pch"; sourceTree = ""; }; FB290D0219B2C406004C83CF /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; FB290D0319B2C406004C83CF /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 38CF8C771C6DF2E48EC4833A /* SceneDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SceneDelegate.h; sourceTree = ""; }; + 82AC1379506ECCBE4DFD56B7 /* SceneDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SceneDelegate.m; sourceTree = ""; }; FB290D0619B2C406004C83CF /* Limelight.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Limelight.xcdatamodel; sourceTree = ""; }; FB290D0819B2C406004C83CF /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; FB290E7819B37D81004C83CF /* iPad.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = iPad.storyboard; sourceTree = SOURCE_ROOT; }; @@ -456,6 +460,8 @@ FB290CFA19B2C406004C83CF /* Supporting Files */, FB290D0219B2C406004C83CF /* AppDelegate.h */, FB290D0319B2C406004C83CF /* AppDelegate.m */, + 38CF8C771C6DF2E48EC4833A /* SceneDelegate.h */, + 82AC1379506ECCBE4DFD56B7 /* SceneDelegate.m */, FBDE86DE19F7A837001C18A8 /* UIComputerView.h */, FBDE86DF19F7A837001C18A8 /* UIComputerView.m */, FBDE86E419F82297001C18A8 /* UIAppView.h */, @@ -891,6 +897,7 @@ FB1A674D2131E65900507771 /* KeyboardSupport.m in Sources */, FB89463219F646E200339C8A /* VideoDecoderRenderer.m in Sources */, FB290D0419B2C406004C83CF /* AppDelegate.m in Sources */, + 373DBA3BACAB43B6ACB1140E /* SceneDelegate.m in Sources */, FB9AFD401A7E127D00872C98 /* AppListResponse.m in Sources */, FB89463419F646E200339C8A /* Utils.m in Sources */, FBDE86E619F82297001C18A8 /* UIAppView.m in Sources */, @@ -1360,6 +1367,7 @@ FB290D0519B2C406004C83CF /* Limelight.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 6F1029036A2BD5FD8A00A1A0 /* Moonlight v1.11.xcdatamodel */, 9874E8962AE95E1D00130A3C /* Moonlight v1.10.xcdatamodel */, 986E28A528EA989100758361 /* Moonlight v1.9.xcdatamodel */, 984CD023288A310D0097D2D4 /* Moonlight v1.8.xcdatamodel */, @@ -1376,7 +1384,7 @@ FB4678F21A51BDCB00377732 /* Limelight 0.3.0.xcdatamodel */, FB290D0619B2C406004C83CF /* Limelight.xcdatamodel */, ); - currentVersion = 9874E8962AE95E1D00130A3C /* Moonlight v1.10.xcdatamodel */; + currentVersion = 6F1029036A2BD5FD8A00A1A0 /* Moonlight v1.11.xcdatamodel */; path = Limelight.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/iPad.storyboard b/iPad.storyboard index 9017b15b9..e569856ac 100644 --- a/iPad.storyboard +++ b/iPad.storyboard @@ -122,16 +122,34 @@ -