diff --git a/VoidLink/Stream/VideoDecoderRenderer.m b/VoidLink/Stream/VideoDecoderRenderer.m index 47ea99e8f..4e0a1089f 100644 --- a/VoidLink/Stream/VideoDecoderRenderer.m +++ b/VoidLink/Stream/VideoDecoderRenderer.m @@ -25,6 +25,7 @@ #include #include #include +#include // Define for extra logging related to frame pacing //#define DISPLAYLINK_VERBOSE @@ -72,12 +73,25 @@ - (void)reinitializeDisplayLayer // 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. + // Compute a safe aspect ratio and video size to avoid NaN/Inf bounds + CGFloat viewW = _view.bounds.size.width; + CGFloat viewH = _view.bounds.size.height; + float ar = _streamAspectRatio; + if (!isfinite(ar) || ar <= 0.0f) { + if (viewH > 0.0) ar = (float)(viewW / viewH); + if (!isfinite(ar) || ar <= 0.0f) ar = 16.0f/9.0f; + } + if (!(viewW > 0.0)) viewW = 1.0; + if (!(viewH > 0.0)) viewH = 1.0; + CGSize videoSize; - if (_view.bounds.size.width > _view.bounds.size.height * _streamAspectRatio) { - videoSize = CGSizeMake(_view.bounds.size.height * _streamAspectRatio, _view.bounds.size.height); + if (viewW > viewH * ar) { + videoSize = CGSizeMake(viewH * ar, viewH); } else { - videoSize = CGSizeMake(_view.bounds.size.width, _view.bounds.size.width / _streamAspectRatio); + videoSize = CGSizeMake(viewW, viewW / ar); } + if (!isfinite(videoSize.width) || videoSize.width <= 0.0) videoSize.width = MAX(viewW, 1.0); + if (!isfinite(videoSize.height) || videoSize.height <= 0.0) videoSize.height = MAX(viewH, 1.0); [CATransaction begin]; [CATransaction setDisableActions:YES]; diff --git a/VoidLink/ViewControllers/MainFrameViewController.h b/VoidLink/ViewControllers/MainFrameViewController.h index 33cf4d4f2..3f520d995 100644 --- a/VoidLink/ViewControllers/MainFrameViewController.h +++ b/VoidLink/ViewControllers/MainFrameViewController.h @@ -36,6 +36,9 @@ - (bool)isIPhonePortrait; - (void)quitRunningApp; - (NSInteger)requestForBitrate:(NSInteger)bitrateKbps; +// Resume the currently running app (if any) immediately, using +// updated resolution derived from current window metrics. +- (void)resumeRunningAppAfterResize; #endif - (void)fillResolutionTable:(CGSize*)resolutionTable externalDisplayMode:(NSInteger)externalDisplayMode; - (bool)isIPhone; diff --git a/VoidLink/ViewControllers/MainFrameViewController.m b/VoidLink/ViewControllers/MainFrameViewController.m index e1e870609..1528b2acb 100644 --- a/VoidLink/ViewControllers/MainFrameViewController.m +++ b/VoidLink/ViewControllers/MainFrameViewController.m @@ -85,6 +85,14 @@ @implementation MainFrameViewController { } static NSMutableSet* hostList; +- (void)onRealtimeAdaptationResumeRequest:(NSNotification *)note { + [self resumeRunningAppAfterResize]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self name:@"VLRealtimeAdaptationResumeRequest" object:nil]; +} + - (void)startPairing:(NSString *)PIN { // Needs to be synchronous to ensure the alert is shown before any potential // failure callback could be invoked. @@ -103,6 +111,80 @@ - (void)startPairing:(NSString *)PIN { }); } +- (void)resumeRunningAppAfterResize { + static int s_autoAdaptResumeTries = 0; + static BOOL s_autoAdaptResumeInFlight = NO; + if (s_autoAdaptResumeInFlight) { + Log(LOG_W, @"[Auto-Adapt] Resume already in flight; ignoring duplicate request"); + return; + } + Log(LOG_I, @"[Auto-Adapt] Resume request received (try %d)", s_autoAdaptResumeTries + 1); + // Find a host with a running app. Prefer the selected host if set. + TemporaryHost *host = _selectedHost; + if (host == nil && hostList.count > 0) { + for (TemporaryHost *h in hostList) { + if (h.currentGame && ![h.currentGame isEqualToString:@"0"]) { + host = h; + break; + } + } + } + if (host == nil) { + Log(LOG_W, @"[Auto-Adapt] No host available to resume after resize"); + if (s_autoAdaptResumeTries++ < 5) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self resumeRunningAppAfterResize]; + }); + } else { + s_autoAdaptResumeTries = 0; + } + return; + } + // Ensure updateAppsForHost: doesn't early-return + _selectedHost = host; + TemporaryApp *currentApp = [self findRunningApp:host]; + if (currentApp == nil && launchedApp != nil) { + // Fallback to previously launched app if discovery hasn't populated yet + Log(LOG_I, @"[Auto-Adapt] Using previously launched app: %@", launchedApp.name); + currentApp = launchedApp; + } + if (currentApp == nil) { + // Fallback: ensure app list is up-to-date and pick the first app + [self updateAppsForHost:host]; + if (host.appList.count == 0) { + Log(LOG_W, @"[Auto-Adapt] No apps on host; will retry"); + if (s_autoAdaptResumeTries++ < 5) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self resumeRunningAppAfterResize]; + }); + } else { + s_autoAdaptResumeTries = 0; + } + return; + } + if (_sortedAppList != nil && _sortedAppList.count > 0) { + currentApp = _sortedAppList.firstObject; + } + if (currentApp == nil) { + NSArray *apps = [host.appList allObjects]; + if (apps.count > 0) { + currentApp = apps.firstObject; + } + } + } + + // Prepare stream config (includes resolution update based on window) + Log(LOG_I, @"[Auto-Adapt] Resuming app: %@", currentApp.name); + s_autoAdaptResumeInFlight = YES; + [self prepareToStreamApp:currentApp]; + [self performSegueWithIdentifier:@"createStreamFrame" sender:nil]; + s_autoAdaptResumeTries = 0; + // Clear in-flight flag after a short grace period to allow future auto-adapt cycles + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + s_autoAdaptResumeInFlight = NO; + }); +} + - (void)displayPairingFailureDialog:(NSString *)message { UIAlertController* failedDialog = [UIAlertController alertControllerWithTitle:[LocalizationHelper localizedStringForKey:@"Pairing Failed"] message:message @@ -1534,6 +1616,9 @@ - (void)viewDidLoad{ DataManager* dataMan = [[DataManager alloc] init]; TemporarySettings* tempSettings = [dataMan getSettings]; [ThemeManager setUserInterfaceStyle:tempSettings.appTheme.intValue]; + // Listen for real-time adaptation resume requests + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onRealtimeAdaptationResumeRequest:) name:@"VLRealtimeAdaptationResumeRequest" object:nil]; + Log(LOG_I, @"[Auto-Adapt] Observer registered on MainFrameViewController"); #if !TARGET_OS_TV self.settingsExpandedInStreamView = false; // init this flag diff --git a/VoidLink/ViewControllers/SettingsViewController.h b/VoidLink/ViewControllers/SettingsViewController.h index 3a3805fd2..61bdaec3d 100644 --- a/VoidLink/ViewControllers/SettingsViewController.h +++ b/VoidLink/ViewControllers/SettingsViewController.h @@ -62,6 +62,10 @@ @property (strong, nonatomic) IBOutlet UIStackView *frameQueueSizeStack; @property (strong, nonatomic) IBOutlet UIStackView *performanceGraphStack; +// Programmatic: Real-time window adaptation toggle (under Video section, below PiP) +@property (strong, nonatomic) UIStackView *realtimeAdaptationStack; +@property (strong, nonatomic) UISwitch *realtimeAdaptationSwitch; + @property (strong, nonatomic) IBOutlet UILabel *bitrateLabel; @property (strong, nonatomic) IBOutlet UISlider *bitrateSlider; @property (strong, nonatomic) IBOutlet UISegmentedControl *framerateSelector; diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index e58d97de2..5cf2b4881 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -718,6 +718,33 @@ - (void)layoutSections{ [self addSetting:self.hdrStack ofId:@"hdrStack" withInfoTag:![self hdrSupported] withDynamicLabel:NO to:videoSection]; [self addSetting:self.yuv444Stack ofId:@"yuv444Stack" withInfoTag:YES withDynamicLabel:NO to:videoSection]; [self addSetting:self.pipStack ofId:@"pipStack" withInfoTag:YES withDynamicLabel:NO to:videoSection]; + + // Add "Real-time window adaptation" toggle below PiP + if (!self.realtimeAdaptationStack) { + UILabel *label = [[UILabel alloc] init]; + label.text = [LocalizationHelper localizedStringForKey:@"Real-time window adaptation"]; + label.textColor = [UIColor labelColor]; + label.numberOfLines = 1; + + self.realtimeAdaptationSwitch = [[UISwitch alloc] init]; + BOOL rtEnabled = [[NSUserDefaults standardUserDefaults] boolForKey:@"VLRealtimeWindowAdaptationEnabled"]; + self.realtimeAdaptationSwitch.on = rtEnabled; + [self.realtimeAdaptationSwitch addTarget:self action:@selector(realtimeAdaptationSwitchFlipped:) forControlEvents:UIControlEventValueChanged]; + + UIStackView *row = [[UIStackView alloc] initWithArrangedSubviews:@[label, self.realtimeAdaptationSwitch]]; + row.axis = UILayoutConstraintAxisHorizontal; + row.alignment = UIStackViewAlignmentCenter; + row.distribution = UIStackViewDistributionFill; + row.spacing = 8.0; + row.translatesAutoresizingMaskIntoConstraints = NO; + + // Hugging/compression priorities to keep switch at right + [label setContentHuggingPriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisHorizontal]; + [self.realtimeAdaptationSwitch setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal]; + + self.realtimeAdaptationStack = row; + } + [self addSetting:self.realtimeAdaptationStack ofId:@"realtimeAdaptationStack" withInfoTag:NO withDynamicLabel:NO to:videoSection]; [self addSetting:self.framePacingStack ofId:@"framePacingStack" withInfoTag:YES withDynamicLabel:NO to:videoSection]; [self addSetting:self.frameQueueSizeStack ofId:@"frameQueueSizeStack" withInfoTag:NO withDynamicLabel:YES to:videoSection]; @@ -869,6 +896,13 @@ - (void)layoutSections{ // [experimentalSection setExpanded:NO]; } +#pragma mark - Real-time window adaptation + +- (void)realtimeAdaptationSwitchFlipped:(UISwitch* )sender { + [[NSUserDefaults standardUserDefaults] setBool:sender.isOn forKey:@"VLRealtimeWindowAdaptationEnabled"]; + [[NSUserDefaults standardUserDefaults] synchronize]; +} + - (void)handleAutoScroll:(CGPoint)location{ bool scrollDown = location.y > self.view.bounds.size.height - 100; diff --git a/VoidLink/ViewControllers/StreamFrameViewController.m b/VoidLink/ViewControllers/StreamFrameViewController.m index 232f04828..a839a67ff 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.m +++ b/VoidLink/ViewControllers/StreamFrameViewController.m @@ -81,6 +81,11 @@ @implementation StreamFrameViewController { BOOL _isRestoringFromPiP; CADisplayLink *_displayLink; + // Real-time window adaptation state + dispatch_block_t _autoAdaptQuitResumeBlock; + BOOL _autoAdaptQuitResumeInProgress; + + #if !TARGET_OS_TV CustomEdgeSlideGestureRecognizer *_slideToSettingsRecognizer; CustomEdgeSlideGestureRecognizer *_slideToToolboxRecognizer; @@ -693,6 +698,16 @@ - (void)viewDidLoad name:@"OscLayoutCloseNotification" object:nil]; + // Observe Menu Bar (iOS 18+) button actions via notifications + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(expandSettingsView) + name:@"VLMenuBarOpenSettings" + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(bringUpToolboxMenu) + name:@"VLMenuBarOpenToolbox" + object:nil]; + #if 0 // FIXME: This doesn't work reliably on iPad for some reason. Showing and hiding the keyboard // several times in a row will not correctly restore the state of the UIScrollView. @@ -1161,6 +1176,8 @@ - (void)disconnectAndQuitApp{ - (void) connectionStarted { Log(LOG_I, @"Connection started"); dispatch_async(dispatch_get_main_queue(), ^{ + // + // Leave the spinner spinning until it's obscured by // the first frame of video. self->_stageLabel.hidden = YES; @@ -1192,6 +1209,8 @@ - (void) connectionStarted { - (void)connectionTerminated:(int)errorCode { Log(LOG_I, @"Connection terminated: %d", errorCode); + + // orderly-termination branch removed unsigned int portFlags = LiGetPortFlagsFromTerminationErrorCode(errorCode); unsigned int portTestResults = LiTestClientConnectivity(CONN_TEST_SERVER, 443, portFlags); @@ -1631,7 +1650,7 @@ - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id_autoAdaptQuitResumeInProgress = NO; + }); +} + +- (CGSize)currentWindowPixelSize { + UIWindow *window = _deviceWindow ? _deviceWindow : self.view.window; + if (!window) { + UIScreen *screen = [UIScreen mainScreen]; + return CGSizeMake(self.view.bounds.size.width * screen.scale, + self.view.bounds.size.height * screen.scale); + } + CGFloat scale = window.screen.scale; + CGFloat w = window.frame.size.width * scale; + CGFloat h = window.frame.size.height * scale; + if (UIScreen.screens.count > 1 && [self isAirPlayEnabled]) { + CGRect bounds = [UIScreen.screens.lastObject bounds]; + scale = [UIScreen.screens.lastObject scale]; + w = bounds.size.width * scale; + h = bounds.size.height * scale; + } + return CGSizeMake(w, h); +} + +// removed old in-place restart path (scheduleAutoAdaptRestartWithDelay / performAutoAdaptRestart) + + @end