Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions VoidLink/Stream/VideoDecoderRenderer.m
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
#include <libavformat/avio.h>
#include <libavutil/mem.h>
#include <mach/mach_time.h>
#include <math.h>

// Define for extra logging related to frame pacing
//#define DISPLAYLINK_VERBOSE
Expand Down Expand Up @@ -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];
Expand Down
3 changes: 3 additions & 0 deletions VoidLink/ViewControllers/MainFrameViewController.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
85 changes: 85 additions & 0 deletions VoidLink/ViewControllers/MainFrameViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions VoidLink/ViewControllers/SettingsViewController.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions VoidLink/ViewControllers/SettingsViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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;
Expand Down
116 changes: 114 additions & 2 deletions VoidLink/ViewControllers/StreamFrameViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1631,23 +1650,34 @@ - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIVi
return;
}

Log(LOG_I, @"View size changed, terminating stream");
Log(LOG_I, @"View size changed, reconfiguring");

double delayInSeconds = 0.2;
if (_delayedRemoveExtScreen) {
dispatch_block_cancel(_delayedRemoveExtScreen);
}
dispatch_block_t block = dispatch_block_create(0, ^{
[self handleViewResize];
[self reConfigStreamViewRealtimeAndReloadSettings:YES];
// Avoid heavy reconfiguration when real-time adaptation is enabled;
// the stream will be restarted shortly with the new resolution.
if (![self isRealtimeAdaptationEnabled]) {
[self reConfigStreamViewRealtimeAndReloadSettings:YES];
}
});
_delayedRemoveExtScreen = block;
dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
dispatch_after(delayTime, dispatch_get_main_queue(), block);

// Real-time adaptation: simple return-and-resume after stabilization
if ([self isRealtimeAdaptationEnabled] && !_autoAdaptQuitResumeInProgress) {
[self scheduleAutoAdaptQuitAndResumeWithDelay:0];
}
}

- (void)dealloc {
[self stopDisplayLink];
[[NSNotificationCenter defaultCenter] removeObserver:self name:@"VLMenuBarOpenSettings" object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:@"VLMenuBarOpenToolbox" object:nil];
}

- (void)setupDisplayLink {
Expand All @@ -1672,4 +1702,86 @@ - (void)displayLinkTick:(CADisplayLink *)link {
}


#pragma mark - Real-time Window Adaptation

- (BOOL)isRealtimeAdaptationEnabled {
return [[NSUserDefaults standardUserDefaults] boolForKey:@"VLRealtimeWindowAdaptationEnabled"];
}

- (void)setRealtimeAdaptationEnabled:(BOOL)enabled {
[[NSUserDefaults standardUserDefaults] setBool:enabled forKey:@"VLRealtimeWindowAdaptationEnabled"];
[[NSUserDefaults standardUserDefaults] synchronize];
}

- (void)toggleRealtimeAdaptation {
BOOL enabled = ![self isRealtimeAdaptationEnabled];
[self setRealtimeAdaptationEnabled:enabled];
Log(LOG_I, @"Real-time window adaptation: %s", enabled ? "ON" : "OFF");
NSString *msg = enabled ? @"Real-time window adaptation: ON" : @"Real-time window adaptation: OFF";
[self updateOverlayText:msg];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self updateOverlayText:nil];
});
}

- (void)scheduleAutoAdaptQuitAndResumeWithDelay:(NSTimeInterval)delaySeconds {
if (_autoAdaptQuitResumeInProgress) {
return;
}
if (_autoAdaptQuitResumeBlock) {
dispatch_block_cancel(_autoAdaptQuitResumeBlock);
_autoAdaptQuitResumeBlock = nil;
}
__weak typeof(self) weakSelf = self;
dispatch_block_t block = dispatch_block_create(0, ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
[strongSelf performAutoAdaptQuitAndResume];
});
_autoAdaptQuitResumeBlock = block;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delaySeconds * NSEC_PER_SEC)), dispatch_get_main_queue(), block);
}

- (void)performAutoAdaptQuitAndResume {
if (_autoAdaptQuitResumeInProgress) return;
_autoAdaptQuitResumeInProgress = YES;

// Return to main frame, then instruct it to resume the running app.
// We dispatch the resume after a slight delay to ensure navigation popped.
[self returnToMainFrame];
// Defer resume to MainFrameViewController via notification to avoid timing/ownership issues
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
Log(LOG_I, @"[Auto-Adapt] Posting resume request notification");
[[NSNotificationCenter defaultCenter] postNotificationName:@"VLRealtimeAdaptationResumeRequest" object:nil];
// Also directly invoke on MainFrame to be robust against notification timing
if (self.mainFrameViewcontroller && [self.mainFrameViewcontroller respondsToSelector:@selector(resumeRunningAppAfterResize)]) {
Log(LOG_I, @"[Auto-Adapt] Direct resume invoke on MainFrameViewController");
[self.mainFrameViewcontroller resumeRunningAppAfterResize];
}
self->_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