From 3b165d53083a5a957780b96841450800511f0ff0 Mon Sep 17 00:00:00 2001 From: Jeongryeol Yu Date: Thu, 29 Oct 2026 19:27:12 +0900 Subject: [PATCH 1/7] Adopt the UIScene life cycle Apps built with the iOS 27 SDK are terminated at launch unless they adopt the scene-based life cycle. Add a scene manifest (with an iPad variant for the iPad storyboard) and a scene delegate that handles home screen quick actions, and let the alert and loading frame presenters find a window before one has become key. --- Limelight/AppDelegate.m | 14 +------ Limelight/Limelight-Info.plist | 42 +++++++++++++++++-- Limelight/SceneDelegate.h | 14 +++++++ Limelight/SceneDelegate.m | 32 ++++++++++++++ .../LoadingFrameViewController.m | 14 ++++++- .../ViewControllers/MainFrameViewController.m | 4 +- Moonlight.xcodeproj/project.pbxproj | 6 +++ 7 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 Limelight/SceneDelegate.h create mode 100644 Limelight/SceneDelegate.m 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/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/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/ViewControllers/LoadingFrameViewController.m b/Limelight/ViewControllers/LoadingFrameViewController.m index 550ea60ac..8a2d4f1de 100644 --- a/Limelight/ViewControllers/LoadingFrameViewController.m +++ b/Limelight/ViewControllers/LoadingFrameViewController.m @@ -19,8 +19,18 @@ - (void)viewDidLoad { } - (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..e2fc35b3d 100644 --- a/Limelight/ViewControllers/MainFrameViewController.m +++ b/Limelight/ViewControllers/MainFrameViewController.m @@ -436,7 +436,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; diff --git a/Moonlight.xcodeproj/project.pbxproj b/Moonlight.xcodeproj/project.pbxproj index e686ec129..90ed0dc61 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 */; }; @@ -239,6 +240,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 +459,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 +896,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 */, From 1591fd2cd2f4785446326b97bfe6dc135e7ee732 Mon Sep 17 00:00:00 2001 From: Jeongryeol Yu Date: Thu, 29 Oct 2026 21:15:32 +0900 Subject: [PATCH 2/7] Lay out the stream and menus again when the display changes iPhone Duo moves the scene between displays of different sizes when it is folded or unfolded mid-stream, and iPad windows can be resized. Keep the stream view, video layer, labels and on-screen controls sized to the view, use the large screen control layout on displays that are regular-sized in both dimensions, and keep controls off active fold (division) regions. Also opt the stream out of the vertical bar, release on-screen controls when touches are cancelled, clamp the frame rate to the display the stream starts on, and keep the host list, app tiles, loading spinner and settings padding right across display changes. Fill the area above the navigation bar with the bar color instead of leaving a black band at the top of the screen. --- Limelight/Input/OnScreenControls.h | 1 + Limelight/Input/OnScreenControls.m | 174 +++++++++++++++--- Limelight/Input/StreamView.m | 27 +++ .../LoadingFrameViewController.m | 6 + .../ViewControllers/MainFrameViewController.m | 54 +++++- .../ViewControllers/SWRevealViewController.m | 12 ++ .../ViewControllers/SettingsViewController.m | 16 +- .../StreamFrameViewController.m | 35 ++++ 8 files changed, 286 insertions(+), 39 deletions(-) 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.m b/Limelight/Input/StreamView.m index 7dedeb35e..d7759e582 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" @@ -184,6 +185,28 @@ - (CGSize) getVideoAreaSize { } } +- (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 { // These are now relative to the StreamView, however we need to scale them // further to make them relative to the actual video portion. @@ -615,6 +638,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 diff --git a/Limelight/ViewControllers/LoadingFrameViewController.m b/Limelight/ViewControllers/LoadingFrameViewController.m index 8a2d4f1de..7eb95c036 100644 --- a/Limelight/ViewControllers/LoadingFrameViewController.m +++ b/Limelight/ViewControllers/LoadingFrameViewController.m @@ -18,6 +18,12 @@ - (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 { UIWindow *window = [UIApplication sharedApplication].keyWindow; if (window == nil) { diff --git a/Limelight/ViewControllers/MainFrameViewController.m b/Limelight/ViewControllers/MainFrameViewController.m index e2fc35b3d..def92e763 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 @@ -616,9 +617,11 @@ - (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); } } @@ -921,6 +924,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]; @@ -944,6 +987,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.m b/Limelight/ViewControllers/SettingsViewController.m index bbe8eb2de..0b89492f8 100644 --- a/Limelight/ViewControllers/SettingsViewController.m +++ b/Limelight/ViewControllers/SettingsViewController.m @@ -16,6 +16,7 @@ @implementation SettingsViewController { NSInteger _bitrate; NSInteger _lastSelectedResolutionIndex; + BOOL _safeAreaPadded; } @dynamic overrideUserInterfaceStyle; @@ -101,13 +102,16 @@ - (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; } } } diff --git a/Limelight/ViewControllers/StreamFrameViewController.m b/Limelight/ViewControllers/StreamFrameViewController.m index a4c8f3448..9f3356918 100644 --- a/Limelight/ViewControllers/StreamFrameViewController.m +++ b/Limelight/ViewControllers/StreamFrameViewController.m @@ -211,6 +211,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; } @@ -703,6 +730,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 From f1dcc09e2349a251c5cb411b753aa12a566730cb Mon Sep 17 00:00:00 2001 From: Jeongryeol Yu Date: Thu, 29 Oct 2026 21:15:32 +0900 Subject: [PATCH 3/7] Create the stream display link from the window scene A display link from the window scene is retargeted when the scene moves to another display, like when iPhone Duo is folded or unfolded mid-stream, so frame pacing follows the display the stream is on. The window scene can only be read on the main thread, so create the display link there, and invalidate it there too so no callback can run after stop returns. This can't deadlock because LiStopConnection() is always called on a background queue (see Connection.m), so the main thread never waits for the renderer to stop. --- Limelight/Stream/VideoDecoderRenderer.m | 51 ++++++++++++++++++++----- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/Limelight/Stream/VideoDecoderRenderer.m b/Limelight/Stream/VideoDecoderRenderer.m index 5c89e5ade..37fe88553 100644 --- a/Limelight/Stream/VideoDecoderRenderer.m +++ b/Limelight/Stream/VideoDecoderRenderer.m @@ -99,16 +99,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 +165,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 From 70fe91dc0b713a223deea3fe9caba354252d61e4 Mon Sep 17 00:00:00 2001 From: Jeongryeol Yu Date: Thu, 29 Oct 2026 19:27:34 +0900 Subject: [PATCH 4/7] Match the full screen and safe area resolutions to the current display Save the full screen and safe area options as special values that are resolved when a stream starts, so they match whichever display the stream is on (like the inner and outer displays of iPhone Duo) instead of the one the settings were opened on. Compute them from the native scale, round them to the even dimensions video encoders require (the inner display of iPhone Duo used to request 2853x2007), and leave room for the larger safe area inset on both sides since the video is centered. Also recognize sizes saved by older versions as these options, keep custom resolutions custom even when they match the current display, and round custom resolutions to even dimensions. --- Limelight/Database/TemporarySettings.h | 6 + Limelight/Utility/Utils.h | 1 + Limelight/Utility/Utils.m | 26 +++++ .../ViewControllers/MainFrameViewController.m | 15 ++- .../ViewControllers/SettingsViewController.m | 105 ++++++++++++++---- 5 files changed, 132 insertions(+), 21 deletions(-) diff --git a/Limelight/Database/TemporarySettings.h b/Limelight/Database/TemporarySettings.h index da35b30c1..8dc66d5a4 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; 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/MainFrameViewController.m b/Limelight/ViewControllers/MainFrameViewController.m index def92e763..b862a7bd0 100644 --- a/Limelight/ViewControllers/MainFrameViewController.m +++ b/Limelight/ViewControllers/MainFrameViewController.m @@ -626,8 +626,19 @@ - (void) prepareToStreamApp:(TemporaryApp *)app { } } - _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; diff --git a/Limelight/ViewControllers/SettingsViewController.m b/Limelight/ViewControllers/SettingsViewController.m index 0b89492f8..90f99141b 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 @@ -52,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; @@ -68,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 @@ -117,11 +144,14 @@ - (void)viewSafeAreaInsetsDidChange { } 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; } @@ -130,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]; @@ -144,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:)]; @@ -160,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); } @@ -184,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; + } } } @@ -409,6 +460,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]; @@ -531,6 +586,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; From aa42f261aafb1ecdb137a6b01ff7acc3b4f7f2d6 Mon Sep 17 00:00:00 2001 From: Jeongryeol Yu Date: Thu, 29 Oct 2026 21:16:04 +0900 Subject: [PATCH 5/7] Fix mouse input on iPhone with AssistiveTouch iPhone only supports mice through AssistiveTouch. It reports the mouse through GCMouse but doesn't always deliver its buttons there, and the system pointer was drawn on top of the host's cursor. On iPhone, fall back to the UIKit pointer button events until GCMouse delivers a button event and then use only GCMouse's, and keep the system pointer hidden over the stream while GCMouse moves the cursor. --- Limelight/Input/ControllerSupport.h | 3 +++ Limelight/Input/ControllerSupport.m | 12 ++++++++++++ Limelight/Input/StreamView.m | 16 ++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) 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/StreamView.m b/Limelight/Input/StreamView.m index d7759e582..9933d4c3d 100644 --- a/Limelight/Input/StreamView.m +++ b/Limelight/Input/StreamView.m @@ -41,6 +41,7 @@ @implementation StreamView { UIResponder* touchHandler; id interactionDelegate; + __weak ControllerSupport* controllerSupport; NSTimer* interactionTimer; BOOL hasUserInteracted; @@ -51,6 +52,7 @@ - (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]; @@ -476,7 +478,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; } @@ -686,7 +693,12 @@ - (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; } } From acfd980d2896f6da6814acbf116f3c9659d0ccb8 Mon Sep 17 00:00:00 2001 From: Jeongryeol Yu Date: Thu, 29 Oct 2026 21:16:27 +0900 Subject: [PATCH 6/7] Require a deliberate swipe to end the stream Swiping from the left edge ended the stream as soon as the swipe was recognized, so tapping or dragging near that edge, like on a dock at the side of the host's desktop, could disconnect by accident. Only end the stream when a swipe from the edge is completed and covers at least 100 points, and only accept finger touches so dragging a mouse along the edge doesn't count. --- .../StreamFrameViewController.m | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/Limelight/ViewControllers/StreamFrameViewController.m b/Limelight/ViewControllers/StreamFrameViewController.m index 9f3356918..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; @@ -388,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"); From 2474046bb4abf74a9254ef3d2b8a9b2da401ab24 Mon Sep 17 00:00:00 2001 From: Jeongryeol Yu Date: Thu, 29 Oct 2026 19:27:45 +0900 Subject: [PATCH 7/7] Add a video scaling option (Fit, Fill, Stretch) Let users fill displays whose aspect ratio differs from the host's, like the 1.42:1 inner display of iPhone Duo streaming a 16:9 desktop. Fit keeps the current behavior, Fill crops the edges of the video that don't fit and Stretch distorts the video to fill the screen. StreamView now computes the video region for every mode, and the video layer, touch and mouse mapping and pointer region all use it. The setting is stored in a new Core Data model version (Moonlight v1.11), which is migrated automatically. --- Limelight/Database/DataManager.h | 3 +- Limelight/Database/DataManager.m | 6 +- Limelight/Database/TemporarySettings.h | 5 ++ Limelight/Database/TemporarySettings.m | 1 + Limelight/Input/StreamView.h | 1 + Limelight/Input/StreamView.m | 62 ++++++++++++------ .../Limelight.xcdatamodeld/.xccurrentversion | 2 +- .../Moonlight v1.11.xcdatamodel/contents | 44 +++++++++++++ Limelight/Stream/StreamManager.m | 2 +- Limelight/Stream/VideoDecoderRenderer.h | 2 +- Limelight/Stream/VideoDecoderRenderer.m | 25 +++----- .../ViewControllers/SettingsViewController.h | 1 + .../ViewControllers/SettingsViewController.m | 5 +- Moonlight.xcodeproj/project.pbxproj | 4 +- iPad.storyboard | 63 ++++++++++++------- iPhone.storyboard | 63 ++++++++++++------- 16 files changed, 202 insertions(+), 87 deletions(-) create mode 100644 Limelight/Limelight.xcdatamodeld/Moonlight v1.11.xcdatamodel/contents 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 8dc66d5a4..cbef23d4e 100644 --- a/Limelight/Database/TemporarySettings.h +++ b/Limelight/Database/TemporarySettings.h @@ -40,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/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 9933d4c3d..8daf24cb2 100644 --- a/Limelight/Input/StreamView.m +++ b/Limelight/Input/StreamView.m @@ -26,7 +26,8 @@ @implementation StreamView { NSMutableSet* keysDown; float streamAspectRatio; - + int videoScalingMode; + // iOS 13.4 mouse support NSInteger lastMouseButtonMask; float lastMouseX; @@ -56,7 +57,11 @@ - (void) setupStreamView:(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]; @@ -180,10 +185,33 @@ - (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); + } } } @@ -233,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); @@ -703,25 +731,19 @@ - (UIPointerRegion *)pointerInteraction:(UIPointerInteraction *)interaction } } - // 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.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/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 37fe88553..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]; 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 90f99141b..c789384c2 100644 --- a/Limelight/ViewControllers/SettingsViewController.m +++ b/Limelight/ViewControllers/SettingsViewController.m @@ -295,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]; @@ -609,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 @@ -624,7 +626,8 @@ - (void) saveSettings { enableHdr:enableHdr btMouseSupport:btMouseSupport absoluteTouchMode:absoluteTouchMode - statsOverlay:statsOverlay]; + statsOverlay:statsOverlay + videoScalingMode:videoScalingMode]; } - (void)didReceiveMemoryWarning { diff --git a/Moonlight.xcodeproj/project.pbxproj b/Moonlight.xcodeproj/project.pbxproj index 90ed0dc61..8af8ccbe4 100644 --- a/Moonlight.xcodeproj/project.pbxproj +++ b/Moonlight.xcodeproj/project.pbxproj @@ -194,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 = ""; }; @@ -1366,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 */, @@ -1382,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 @@ -