From 9a634da3ed2369a52668af39b43b6a5437937cc2 Mon Sep 17 00:00:00 2001 From: True Zhuanjia Date: Sun, 26 Jul 2026 16:07:03 +0800 Subject: [PATCH] - feat: global controller navigation - shorten initial host detection --- VoidLink.xcodeproj/project.pbxproj | 14 +- VoidLink/AboutViewController.swift | 29 +- VoidLink/AlertControllerUtil.swift | 10 + VoidLink/Base.lproj/iPad.storyboard | 215 +- VoidLink/Base.lproj/iPhone.storyboard | 224 +- VoidLink/Database/DataManager.h | 3 +- VoidLink/Database/DataManager.m | 6 +- VoidLink/Database/TemporaryHost.h | 1 + VoidLink/Database/TemporaryHost.m | 2 + VoidLink/Database/TemporarySettings.h | 6 +- VoidLink/Database/TemporarySettings.m | 6 +- .../GamepadNavigationIllustrationHud.swift | 515 +++++ VoidLink/HostCardView.swift | 10 +- .../Contents.json | 24 + .../circle.lefthalf.filled.svg | 11 + .../controllerMouse.imageset/Contents.json | 24 + .../controllerMouse.svg | 11 + .../Contents.json | 24 + .../disableControllerMouse.svg | 12 + .../Contents.json | 24 + .../macwindow.and.pointer.arrow.svg | 12 + .../Contents.json | 24 + .../rectangle.portrait.and.arrow.right.svg | 12 + VoidLink/Input/AbsoluteTouchHandler.h | 1 + VoidLink/Input/ControllerNavigator.swift | 2031 ++++++++++++++++- VoidLink/Input/ControllerSupport.h | 5 + VoidLink/Input/ControllerSupport.m | 593 ++--- VoidLink/Input/ControllerUtil.swift | 442 +++- VoidLink/Input/CustomOSC/OSCProfile.m | 4 +- .../Input/CustomOSC/OSCProfilesManager.swift | 6 +- VoidLink/Input/MotionHandler.swift | 1 - VoidLink/Input/NativeTouchHandler.m | 1 + VoidLink/Input/OnScreenWidgetView.swift | 8 +- VoidLink/Input/PureNativeTouchHandler.m | 2 +- VoidLink/Input/RadialMenuOverlayView.swift | 130 +- VoidLink/Input/RelativeTouchHandler.m | 1 + VoidLink/Input/SafeTimer.swift | 73 +- VoidLink/Input/StreamView.h | 9 +- VoidLink/Input/StreamView.m | 6 +- .../VoidLink v1.0.xcdatamodel/contents | 15 +- VoidLink/Localization/Localizable.xcstrings | 1309 +++++++++-- VoidLink/MenuSectionView.swift | 7 +- VoidLink/Stream/StreamManager.m | 1 + VoidLink/ThemeManager.swift | 22 + VoidLink/UIAppView.h | 36 - VoidLink/UIAppView.m | 291 --- VoidLink/UIAppView.swift | 260 +++ VoidLink/Utility/PublicExtensions.swift | 54 +- VoidLink/Utility/PublicUtils.swift | 63 + .../OSCProfilesTableViewController.swift | 228 +- .../HostCollectionViewController.swift | 2 +- .../ViewControllers/MainFrameViewController.h | 25 +- .../ViewControllers/MainFrameViewController.m | 435 +++- .../ViewControllers/SWRevealViewController.h | 3 + .../ViewControllers/SettingsViewController.h | 26 +- .../ViewControllers/SettingsViewController.m | 471 ++-- .../StreamFrameViewController.h | 7 +- .../StreamFrameViewController.m | 64 +- .../ToolboxViewController.swift | 2 +- VoidLink/VoidLink-Bridging-Header.h | 5 + VoidLink/mul.lproj/iPad.xcstrings | 54 +- VoidLink/mul.lproj/iPhone.xcstrings | 50 +- VoidLinkPreview/AbstractGamepadView.swift | 6 +- VoidLinkPreview/RadialMenuView.swift | 259 ++- VoidLinkPreview/VirtualKeyboardView.swift | 2 +- VoidLinkPreview/WidgetPickerView.swift | 2 +- 66 files changed, 6572 insertions(+), 1659 deletions(-) create mode 100644 VoidLink/GamepadNavigationIllustrationHud.swift create mode 100644 VoidLink/Images.xcassets/circle.lefthalf.filled.imageset/Contents.json create mode 100644 VoidLink/Images.xcassets/circle.lefthalf.filled.imageset/circle.lefthalf.filled.svg create mode 100644 VoidLink/Images.xcassets/controllerMouse.imageset/Contents.json create mode 100644 VoidLink/Images.xcassets/controllerMouse.imageset/controllerMouse.svg create mode 100644 VoidLink/Images.xcassets/disableControllerMouse.imageset/Contents.json create mode 100644 VoidLink/Images.xcassets/disableControllerMouse.imageset/disableControllerMouse.svg create mode 100644 VoidLink/Images.xcassets/macwindow.and.pointer.arrow.imageset/Contents.json create mode 100644 VoidLink/Images.xcassets/macwindow.and.pointer.arrow.imageset/macwindow.and.pointer.arrow.svg create mode 100644 VoidLink/Images.xcassets/rectangle.portrait.and.arrow.right.imageset/Contents.json create mode 100644 VoidLink/Images.xcassets/rectangle.portrait.and.arrow.right.imageset/rectangle.portrait.and.arrow.right.svg delete mode 100644 VoidLink/UIAppView.h delete mode 100644 VoidLink/UIAppView.m create mode 100644 VoidLink/UIAppView.swift diff --git a/VoidLink.xcodeproj/project.pbxproj b/VoidLink.xcodeproj/project.pbxproj index 67ae57dc..a332e952 100644 --- a/VoidLink.xcodeproj/project.pbxproj +++ b/VoidLink.xcodeproj/project.pbxproj @@ -39,6 +39,8 @@ 2A263D4F2FF3BA2800154DEA /* PublicUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A263D4E2FF3BA2800154DEA /* PublicUtils.swift */; }; 2A263D502FF3BA2800154DEA /* PublicUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A263D4E2FF3BA2800154DEA /* PublicUtils.swift */; }; 2A7113FD2F5A6C65001D852D /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7113FC2F5A6C65001D852D /* ThemeManager.swift */; }; + 2A7113FF2F5A6C65001D852D /* UIAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7113FE2F5A6C65001D852D /* UIAppView.swift */; }; + 2A7114012F5A6C65001D852D /* GamepadNavigationIllustrationHud.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7114002F5A6C65001D852D /* GamepadNavigationIllustrationHud.swift */; }; 2AAA4E822F7666C5001E20DC /* ControllerUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A765BC2ECD51FB004784AA /* ControllerUtil.swift */; }; 2AAA4E842F7666EE001E20DC /* LocalizationHelperExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E1CDF92C4FE83400547904 /* LocalizationHelperExtension.swift */; }; 2AAA4E872F985F12001E20DC /* LocalizationHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 84FD0B502C31338D006B5BE5 /* LocalizationHelper.m */; }; @@ -156,7 +158,6 @@ FBD3495019FF2174002D2A60 /* SettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD3494F19FF2174002D2A60 /* SettingsViewController.m */; }; FBD3495319FF36FB002D2A60 /* SWRevealViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD3495219FF36FB002D2A60 /* SWRevealViewController.m */; }; FBD349621A0089F6002D2A60 /* DataManager.m in Sources */ = {isa = PBXBuildFile; fileRef = FBD349611A0089F6002D2A60 /* DataManager.m */; }; - FBDE86E619F82297001C18A8 /* UIAppView.m in Sources */ = {isa = PBXBuildFile; fileRef = FBDE86E519F82297001C18A8 /* UIAppView.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -214,6 +215,8 @@ 2A263D492FF3B3F800154DEA /* PublicExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicExtensions.swift; sourceTree = ""; }; 2A263D4E2FF3BA2800154DEA /* PublicUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicUtils.swift; sourceTree = ""; }; 2A7113FC2F5A6C65001D852D /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; + 2A7113FE2F5A6C65001D852D /* UIAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAppView.swift; sourceTree = ""; }; + 2A7114002F5A6C65001D852D /* GamepadNavigationIllustrationHud.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamepadNavigationIllustrationHud.swift; sourceTree = ""; }; 2AC1E3822F507D80004F76C0 /* GenericUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericUtils.swift; sourceTree = ""; }; 2AC1EAFA2F922602004EDA80 /* UIStackExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIStackExtension.swift; sourceTree = ""; }; 3A4A3A102F0C000100000001 /* OSCProfilesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSCProfilesTableViewController.swift; sourceTree = ""; }; @@ -422,8 +425,6 @@ FBD3495219FF36FB002D2A60 /* SWRevealViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SWRevealViewController.m; sourceTree = ""; }; FBD349601A0089F6002D2A60 /* DataManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = DataManager.h; path = Database/DataManager.h; sourceTree = ""; }; FBD349611A0089F6002D2A60 /* DataManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = DataManager.m; path = Database/DataManager.m; sourceTree = ""; }; - FBDE86E419F82297001C18A8 /* UIAppView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UIAppView.h; sourceTree = ""; }; - FBDE86E519F82297001C18A8 /* UIAppView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UIAppView.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -699,8 +700,8 @@ 848F45692DDCD12500581E36 /* HostCardView.swift */, 2A7113FC2F5A6C65001D852D /* ThemeManager.swift */, 847D2FDA2DDA015F00937A62 /* MenuSectionView.swift */, - FBDE86E419F82297001C18A8 /* UIAppView.h */, - FBDE86E519F82297001C18A8 /* UIAppView.m */, + 2A7113FE2F5A6C65001D852D /* UIAppView.swift */, + 2A7114002F5A6C65001D852D /* GamepadNavigationIllustrationHud.swift */, 180E6E362E93BFDB0067C73A /* AlertControllerUtil.swift */, 1888C1902E24C2C800108730 /* AboutView.swift */, 1888C1912E24C33100108730 /* AboutViewController.swift */, @@ -1154,7 +1155,8 @@ FB290D0419B2C406004C83CF /* AppDelegate.m in Sources */, FB9AFD401A7E127D00872C98 /* AppListResponse.m in Sources */, FB89463419F646E200339C8A /* Utils.m in Sources */, - FBDE86E619F82297001C18A8 /* UIAppView.m in Sources */, + 2A7113FF2F5A6C65001D852D /* UIAppView.swift in Sources */, + 2A7114012F5A6C65001D852D /* GamepadNavigationIllustrationHud.swift in Sources */, FB89462F19F646E200339C8A /* Connection.m in Sources */, FB9AFD321A7D867C00872C98 /* AppAssetRetriever.m in Sources */, 843E93BE2C41528700B1651F /* OrientationHelper.swift in Sources */, diff --git a/VoidLink/AboutViewController.swift b/VoidLink/AboutViewController.swift index 75923149..377faf9e 100644 --- a/VoidLink/AboutViewController.swift +++ b/VoidLink/AboutViewController.swift @@ -10,7 +10,26 @@ import SwiftUI @available(iOS 13.0, *) -@objc class AboutViewController: UIViewController { +@objc class AboutViewController: UIViewController, ControllerUINavigationDelegate { + func getNavigationElements() -> [ControllerNavigationElement] { + var elements: [ControllerNavigationElement] = [] + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .x, action: "ok")) + return elements + } + + func navigateByController(forward: Bool) {} + func navigateByController(downward: Bool) {} + func persistControllerNavigationHighlight() {} + func restoreControllerNavigationHighlight() {} + func restoreControllerNavigationHighlightAfterSettingsModeSwitch() {} + func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) {} + + func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) { + if pressed, navigation.action == "ok" { + self.dismiss(animated: true) + } + } + override func viewDidLoad() { super.viewDidLoad() @@ -32,6 +51,14 @@ import SwiftUI modalPresentationStyle = .formSheet } + + override func viewDidAppear(_ animated: Bool) { + ControllerNavigator.setUINavigationDelegate(self) + } + + override func viewDidDisappear(_ animated: Bool) { + ControllerNavigator.restorePreviousUINavigationDelegate(ifCurrentDelegateIs: self) + } } func isIPhone() -> Bool { diff --git a/VoidLink/AlertControllerUtil.swift b/VoidLink/AlertControllerUtil.swift index dffe5cb9..fbbbe916 100644 --- a/VoidLink/AlertControllerUtil.swift +++ b/VoidLink/AlertControllerUtil.swift @@ -76,6 +76,7 @@ import UIKit @objc public static var actionCancelled:Bool = false @objc public static var alertController:UIAlertController = UIAlertController() @objc public static var cancelButtonString:String = "Cancel" + @objc static var completion: (() -> Void)? = nil @objc public static var autoCompletion:Bool = false @objc public static var isCountingDown:Bool = false @@ -93,6 +94,8 @@ import UIKit let originalMessage = message var isAlertDismissed = false + AlertControllerUtil.completion = completion + guard let viewController = viewController else {return} alertController = UIAlertController( @@ -148,16 +151,19 @@ import UIKit isCountingDown = false isAlertDismissed = true timer.cancel() + if autoCompletion { alertController.message = originalMessage applyLeftAlignment(title: title, message: originalMessage) autoCompletion = false completion?() + AlertControllerUtil.completion = nil alertController.dismiss(animated: false) return } if PublicUtils.isRunningOnMacAsiPadApp && buttonTitle != "" { + AlertControllerUtil.completion = nil alertController.dismiss(animated: false) { showAlert( in: viewController, @@ -176,6 +182,10 @@ import UIKit confirmAction.isEnabled = true alertController.message = originalMessage applyLeftAlignment(title: title, message: originalMessage) + + if #available(iOS 13.0, *) { + ControllerNavigator.setUINavigationDelegate(alertController) + } } else { if !autoCompletion { let countdownMessage = makeCountdownMessage(baseMessage: originalMessage, remainingSeconds: remainingSeconds) diff --git a/VoidLink/Base.lproj/iPad.storyboard b/VoidLink/Base.lproj/iPad.storyboard index 346f1b0c..862f5f11 100644 --- a/VoidLink/Base.lproj/iPad.storyboard +++ b/VoidLink/Base.lproj/iPad.storyboard @@ -71,27 +71,32 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -972,16 +977,16 @@ - + - - - + + - - - - - - - - - - - - - - - - - + + @@ -1056,46 +1041,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2198,6 +2143,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2230,10 +2255,11 @@ - - + + + @@ -2340,6 +2366,7 @@ + @@ -2378,6 +2405,8 @@ + + diff --git a/VoidLink/Base.lproj/iPhone.storyboard b/VoidLink/Base.lproj/iPhone.storyboard index b87a21c9..d01e2267 100644 --- a/VoidLink/Base.lproj/iPhone.storyboard +++ b/VoidLink/Base.lproj/iPhone.storyboard @@ -1600,27 +1600,32 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -3087,16 +3092,16 @@ - + - - - + + - - - - - - - - - - - - - - - - - + + @@ -3171,46 +3156,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -3555,6 +3500,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3588,8 +3613,8 @@ - - + + @@ -3683,6 +3708,7 @@ + @@ -3719,6 +3745,8 @@ + + @@ -3768,19 +3796,19 @@ - + - + - + - + - + diff --git a/VoidLink/Database/DataManager.h b/VoidLink/Database/DataManager.h index d7739c7c..b102bfa9 100644 --- a/VoidLink/Database/DataManager.h +++ b/VoidLink/Database/DataManager.h @@ -146,7 +146,7 @@ typedef NS_ENUM(NSInteger, PencilTickMode) { leftClickDelayMs:(CGFloat)leftClickDelayMs settingsMenuOffset:(CGFloat)settingsMenuOffset passthroughGestures:(BOOL)passthroughGestures - mapControllerToMouse:(BOOL)mapControllerToMouse + enableControllerNavigation:(BOOL)mapControllerToMouse controllerMousePointerVelocity:(CGFloat)controllerMousePointerVelocity controllerMouseExpo:(CGFloat)controllerMouseExpo controllerGyroSwitchMode:(NSInteger)controllerGyroSwitchMode @@ -155,6 +155,7 @@ typedef NS_ENUM(NSInteger, PencilTickMode) { sdrPerformanceWorkaround:(BOOL)sdrPerformanceWorkaround softKeyboardHeight:(CGFloat)softKeyboardHeight globeAsEscape:(BOOL)globeAsEscape + streamingRadialMenuDelay:(CGFloat)streamingRadialMenuDelay backgroundSessionTimer:(NSInteger)backgroundSessionTimer; - (NSArray*) getHosts; diff --git a/VoidLink/Database/DataManager.m b/VoidLink/Database/DataManager.m index b3e574ed..a41c6a15 100644 --- a/VoidLink/Database/DataManager.m +++ b/VoidLink/Database/DataManager.m @@ -123,7 +123,7 @@ leftClickDelayMs:(CGFloat)leftClickDelayMs settingsMenuOffset:(CGFloat)settingsMenuOffset passthroughGestures:(BOOL)passthroughGestures - mapControllerToMouse:(BOOL)mapControllerToMouse + enableControllerNavigation:(BOOL)enableControllerNavigation controllerMousePointerVelocity:(CGFloat)controllerMousePointerVelocity controllerMouseExpo:(CGFloat)controllerMouseExpo controllerGyroSwitchMode:(NSInteger)controllerGyroSwitchMode @@ -132,6 +132,7 @@ sdrPerformanceWorkaround:(BOOL)sdrPerformanceWorkaround softKeyboardHeight:(CGFloat)softKeyboardHeight globeAsEscape:(BOOL)globeAsEscape + streamingRadialMenuDelay:(CGFloat)streamingRadialMenuDelay backgroundSessionTimer:(NSInteger)backgroundSessionTimer{ __block Settings* settingsToSave = settings; @@ -208,11 +209,12 @@ settingsToSave.leftClickDelayMs = [NSNumber numberWithFloat:leftClickDelayMs]; settingsToSave.settingsMenuOffset = [NSNumber numberWithFloat:settingsMenuOffset]; settingsToSave.passthroughGestures = passthroughGestures; - settingsToSave.mapControllerToMouse = mapControllerToMouse; + settingsToSave.enableControllerNavigation = enableControllerNavigation; settingsToSave.controllerMousePointerVelocity = [NSNumber numberWithFloat:controllerMousePointerVelocity]; settingsToSave.controllerMouseExpo = [NSNumber numberWithFloat:controllerMouseExpo]; settingsToSave.softKeyboardHeight = softKeyboardHeight; settingsToSave.globeAsEscape = globeAsEscape; + settingsToSave.streamingRadialMenuDelay = @(streamingRadialMenuDelay); settingsToSave.rememberFoldState = rememberFoldState; [self saveData]; }]; diff --git a/VoidLink/Database/TemporaryHost.h b/VoidLink/Database/TemporaryHost.h index 5ac0b858..c4ba8dde 100644 --- a/VoidLink/Database/TemporaryHost.h +++ b/VoidLink/Database/TemporaryHost.h @@ -24,6 +24,7 @@ @property (atomic, nullable, retain) NSString *localAddress; @property (atomic, nullable, retain) NSString *ipv6Address; @property (atomic, nullable, retain) NSString *mac; +@property (atomic, nullable, retain) NSString *controllerNavigationHighlightedAppID; @property (atomic) int serverCodecModeSupport; NS_ASSUME_NONNULL_BEGIN diff --git a/VoidLink/Database/TemporaryHost.m b/VoidLink/Database/TemporaryHost.m index 58df6e55..8fd4a238 100644 --- a/VoidLink/Database/TemporaryHost.m +++ b/VoidLink/Database/TemporaryHost.m @@ -45,6 +45,7 @@ if(!(host.mac == nil || [host.mac isEqualToString:@"00:00:00:00:00:00"])) self.mac = host.mac; // try to fix invalid mac happens in some cases self.name = host.name; self.uuid = host.uuid; + self.controllerNavigationHighlightedAppID = host.controllerNavigationHighlightedAppID; self.serverCodecModeSupport = host.serverCodecModeSupport; self.serverCert = host.serverCert; @@ -101,6 +102,7 @@ } parentHost.name = self.name; parentHost.uuid = self.uuid; + parentHost.controllerNavigationHighlightedAppID = self.controllerNavigationHighlightedAppID; parentHost.serverCodecModeSupport = self.serverCodecModeSupport; parentHost.pairState = [NSNumber numberWithInt:self.pairState]; } diff --git a/VoidLink/Database/TemporarySettings.h b/VoidLink/Database/TemporarySettings.h index b0c86ba1..4861842f 100644 --- a/VoidLink/Database/TemporarySettings.h +++ b/VoidLink/Database/TemporarySettings.h @@ -107,10 +107,12 @@ @property (nonatomic, retain) NSNumber * controllerMouseLeftButton; @property (nonatomic, retain) NSNumber * controllerMouseRightButton; @property (nonatomic, retain) NSNumber * controllerMouseStick; -@property (nonatomic, retain) NSNumber * controllerMouseSwitch; -@property (nonatomic) BOOL mapControllerToMouse; +@property (nonatomic, retain) NSNumber * localRadialMenuButton; +@property (nonatomic, retain) NSNumber * streamingRadialMenuButton; +@property (nonatomic) BOOL enableControllerNavigation; @property (nonatomic, retain) NSNumber * controllerMousePointerVelocity; @property (nonatomic, retain) NSNumber * controllerMouseExpo; +@property (nonatomic, retain) NSNumber * streamingRadialMenuDelay; @property (nonatomic) BOOL globeAsEscape; // Pencil settings: diff --git a/VoidLink/Database/TemporarySettings.m b/VoidLink/Database/TemporarySettings.m index 4db388e4..72323b3c 100644 --- a/VoidLink/Database/TemporarySettings.m +++ b/VoidLink/Database/TemporarySettings.m @@ -161,13 +161,15 @@ self.ctrlDownForPinch = settings.ctrlDownForPinch; self.settingsMenuOffset = settings.settingsMenuOffset; self.passthroughGestures = settings.passthroughGestures; - self.mapControllerToMouse = settings.mapControllerToMouse; + self.enableControllerNavigation = settings.enableControllerNavigation; self.controllerMouseLeftButton = settings.controllerMouseLeftButton; self.controllerMouseRightButton = settings.controllerMouseRightButton; - self.controllerMouseSwitch = settings.controllerMouseSwitch; + self.localRadialMenuButton = settings.localRadialMenuButton; + self.streamingRadialMenuButton = settings.streamingRadialMenuButton; self.controllerMouseStick = settings.controllerMouseStick; self.controllerMousePointerVelocity = settings.controllerMousePointerVelocity; self.controllerMouseExpo = settings.controllerMouseExpo; + self.streamingRadialMenuDelay = settings.streamingRadialMenuDelay; self.globeAsEscape = settings.globeAsEscape; // Pencil settings: diff --git a/VoidLink/GamepadNavigationIllustrationHud.swift b/VoidLink/GamepadNavigationIllustrationHud.swift new file mode 100644 index 00000000..0bc02456 --- /dev/null +++ b/VoidLink/GamepadNavigationIllustrationHud.swift @@ -0,0 +1,515 @@ +// +// GamepadNavigationIllustrationHud.swift +// VoidLink +// +// Created by True Zhuanjia on 2026/7/14. +// Copyright 2026 True Zhuanjia @ Bilibili. All rights reserved. +// + +import UIKit + +@available(iOS 13.0, *) +@objcMembers +final class GamepadNavigationIllustrationHud: UIView { + private enum KeyContent { + case text(String) + case symbol(String) + } + + private struct Hint { + let control: ControllerElement + let keyContent: KeyContent + let title: String + let isInAction: Bool + } + + private let containerView = UIView() + private let contentStackView = UIStackView() + private var themeObserver: NSObjectProtocol? + private var windowConstraints: [NSLayoutConstraint] = [] + private var trailingConstraint: NSLayoutConstraint? + private static let keyBorderViewTag = 4101 + private static let keyContentViewTag = 4102 + private static var currentHud: GamepadNavigationIllustrationHud? + private static var activeControls = Set() + private static var actionStateMinimumEndTimes: [ControllerElement: CFTimeInterval] = [:] + private static var pendingActionStateWorkItems: [ControllerElement: DispatchWorkItem] = [:] + private static var pendingClearHudWorkItem: DispatchWorkItem? + private static let minimumActionStateDuration: CFTimeInterval = 0.09 + private static let hudScale: CGFloat = PublicUtils.isIPhone ? 0.74 : 1 + private static let hudWidth: CGFloat = 235 + private static let edgeMargin: CGFloat = PublicUtils.isIPhone ? 5 : 24 + private var hints: [Hint] = [ + // Hint(control: .leftStick, keyContent: .text("L"), title: "浏览", isInAction: false), + // Hint(control: .a, keyContent: .text("A"), title: "选择", isInAction: false) + ] + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + observeThemeChanges() + updateTheme() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + observeThemeChanges() + updateTheme() + } + + deinit { + NSLayoutConstraint.deactivate(windowConstraints) + containerView.removeFromSuperview() + if let themeObserver { + NotificationCenter.default.removeObserver(themeObserver) + } + } + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + if let window { + updateTrailingOffset(for: window) + } + } + + @discardableResult + static func showInKeyWindow() -> GamepadNavigationIllustrationHud? { + guard let window = keyWindow(), ControllerUtil.primaryGCController != nil else { + return nil + } + + let hud = currentHud ?? GamepadNavigationIllustrationHud() + currentHud = hud + hud.attach(to: window) + + if let mainFrameVC = ControllerNavigator.radialMenuDelegate as? MainFrameViewController { + DispatchQueue.main.asyncAfter(deadline: .now()+3) { + let streamFrameVC = StreamFrameViewController.sharedInstance() + if ControllerNavigator.radialMenuView == nil, mainFrameVC.isStreaming(), !mainFrameVC.settingsExpandedInStreamView, streamFrameVC?.hasNoPresentedVC == true { + requestHudDetachKeepingMinimumActionDuration() + } + } + } + + return hud + } + + static func updateCurrentTheme() { + currentHud?.updateTheme() + } + + + @objc static func updateNavigationElements(_ elements: [ControllerNavigationElement], forceDisplay: Bool = false) { + if !forceDisplay { + guard ControllerNavigator.enabled, ControllerUtil.primaryGCController != nil else {return} + guard !ControllerNavigator.stickReleasedInRadialMenu else {return} + } + PublicUtils.runOnMain { + let hud = showInKeyWindow() + hud?.updateHints(with: elements) + } + } + + @objc static func updateHud() { + let elements = ControllerNavigator.uiNavigationDelegate?.getNavigationElements() ?? [] + /* + for element in elements { + print("element \(element.action)") + } */ + updateNavigationElements(elements) + } + + static func updateActionState(for control: ControllerElement, isInAction: Bool) { + PublicUtils.runOnMain { + if isInAction { + pendingClearHudWorkItem?.cancel() + pendingClearHudWorkItem = nil + pendingActionStateWorkItems[control]?.cancel() + pendingActionStateWorkItems[control] = nil + actionStateMinimumEndTimes[control] = CACurrentMediaTime() + minimumActionStateDuration + + let didChange = activeControls.insert(control).inserted + guard didChange else { return } + currentHud?.refreshActionStates() + } else { + requestActionStateEnd(for: control) + } + } + } + + static func resetActionStates() { + PublicUtils.runOnMain { + guard !activeControls.isEmpty else { return } + Array(activeControls).forEach { requestActionStateEnd(for: $0) } + } + } + + static func clearHud() { + PublicUtils.runOnMain { + if ControllerNavigator.controllerMouseEnabled { + finishHudDetach() + return + } + requestHudDetachKeepingMinimumActionDuration() + } + } + + func updateTheme() { + let isDark = ThemeManager.userInterfaceStyle() == .dark + + backgroundColor = isDark + ? UIColor(red: 28.0 / 255.0, green: 28.0 / 255.0, blue: 30.0 / 255.0, alpha: 0.62) + : UIColor.white.withAlphaComponent(0.6) + layer.borderColor = (isDark ? UIColor.white.withAlphaComponent(0.10) : UIColor.black.withAlphaComponent(0.06)).cgColor + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = isDark ? 0.34 : 0.12 + layer.shadowRadius = isDark ? 18 : 16 + layer.shadowOffset = CGSize(width: 0, height: 8) + + for (index, row) in contentStackView.arrangedSubviews.enumerated() { + guard hints.indices.contains(index), + let rowStack = row as? UIStackView, + let keyView = rowStack.arrangedSubviews.first, + let titleLabel = rowStack.arrangedSubviews.last as? UILabel else { + continue + } + + let isInAction = hints[index].isInAction + let keyBorderView = keyView.viewWithTag(Self.keyBorderViewTag) ?? keyView + let keyContentView = keyView.viewWithTag(Self.keyContentViewTag) ?? keyView + let keyBackgroundColor = isInAction + ? ThemeManager.appPrimaryColor.withAlphaComponent(isDark ? 0.2 : 0.13) + : ThemeManager.appPrimaryColor.withAlphaComponent(isDark ? 0.09 : 0.05) + keyView.backgroundColor = .clear + keyBorderView.layer.borderColor = ThemeManager.appPrimaryColor.withAlphaComponent(isDark ? 0.69 : 0.6).cgColor + keyContentView.backgroundColor = keyBackgroundColor + keyView.transform = .identity + for subview in keyContentView.subviews { + if let keyLabel = subview as? UILabel { + keyLabel.textColor = isInAction ? ThemeManager.appPrimaryColor.withAlphaComponent(isDark ? 1 : 1) : ThemeManager.appPrimaryColor.withAlphaComponent(isDark ? 0.83 : 0.83) + } else if let imageView = subview as? UIImageView { + imageView.tintColor = isInAction ? ThemeManager.appPrimaryColor.withAlphaComponent(isDark ? 1 : 1) : ThemeManager.appPrimaryColor.withAlphaComponent(isDark ? 0.83 : 0.83) + } + } + titleLabel.textColor = ThemeManager.textColor.withAlphaComponent(isDark ? 0.69 : 0.6) + } + } + + private func setupView() { + translatesAutoresizingMaskIntoConstraints = false + isUserInteractionEnabled = false + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.isUserInteractionEnabled = false + transform = CGAffineTransform(scaleX: Self.hudScale, y: Self.hudScale) + layer.cornerRadius = 18 + layer.cornerCurve = .continuous + layer.borderWidth = 1 + + contentStackView.axis = .vertical + contentStackView.alignment = .leading + contentStackView.spacing = 6 + contentStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentStackView) + + NSLayoutConstraint.activate([ + contentStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + contentStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + contentStackView.topAnchor.constraint(equalTo: topAnchor, constant: 10), + contentStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10) + ]) + + reloadHintRows() + } + + private func updateHints(with elements: [ControllerNavigationElement]) { + let nextHints = elements.compactMap { hint(for: $0) } + hints = nextHints.isEmpty ? [] : nextHints + guard !hints.isEmpty else {return} + reloadHintRows() + updateTheme() + isHidden = hints.isEmpty + } + + private func refreshActionStates() { + hints = hints.map { hint in + Hint( + control: hint.control, + keyContent: hint.keyContent, + title: hint.title, + isInAction: Self.isControlActive(hint.control) + ) + } + updateTheme() + } + + private func reloadHintRows() { + contentStackView.arrangedSubviews.forEach { row in + contentStackView.removeArrangedSubview(row) + row.removeFromSuperview() + } + + hints.forEach { contentStackView.addArrangedSubview(makeRow(for: $0)) } + } + + private func hint(for navigationElement: ControllerNavigationElement) -> Hint? { + return Hint( + control: navigationElement.control, + keyContent: keyContent(for: navigationElement.control), + title: navigationElement.action.localized, + isInAction: navigationElement.isInAction || Self.isControlActive(navigationElement.control) + ) + } + + private func keyContent(for control: ControllerElement) -> KeyContent { + let symbol = control.symbol + return symbol.isEmpty ? .text(control.displayName) : .symbol(symbol) + } + + private func attach(to window: UIWindow) { + if containerView.superview !== window { + NSLayoutConstraint.deactivate(windowConstraints) + windowConstraints.removeAll() + removeFromSuperview() + containerView.removeFromSuperview() + window.addSubview(containerView) + containerView.addSubview(self) + + let trailingConstraint = containerView.trailingAnchor.constraint(equalTo: window.safeAreaLayoutGuide.trailingAnchor) + self.trailingConstraint = trailingConstraint + windowConstraints = [ + containerView.widthAnchor.constraint(equalToConstant: Self.hudWidth * Self.hudScale), + containerView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: Self.hudScale), + trailingConstraint, + containerView.bottomAnchor.constraint(equalTo: window.safeAreaLayoutGuide.bottomAnchor, constant: -Self.edgeMargin), + + widthAnchor.constraint(equalToConstant: Self.hudWidth), + centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + centerYAnchor.constraint(equalTo: containerView.centerYAnchor) + ] + NSLayoutConstraint.activate(windowConstraints) + } + + updateTrailingOffset(for: window) + window.bringSubviewToFront(containerView) + } + + private func detachFromWindow() { + NSLayoutConstraint.deactivate(windowConstraints) + windowConstraints.removeAll() + trailingConstraint = nil + removeFromSuperview() + containerView.removeFromSuperview() + } + + private func updateTrailingOffset(for window: UIWindow) { + let horizontalMargin = Self.edgeMargin + (PublicUtils.isIPhone ? 0 : window.safeAreaInsets.bottom) + trailingConstraint?.constant = -horizontalMargin + } + + private func makeRow(for hint: Hint) -> UIStackView { + let keyView = makeKeyView(for: hint.keyContent) + + let titleLabel = UILabel() + titleLabel.text = hint.title + titleLabel.font = UIFont.roundedSystemFont(ofSize: 14, weight: .medium) + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = 0.2 + titleLabel.numberOfLines = 1 + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let rowStackView = UIStackView(arrangedSubviews: [keyView, titleLabel]) + rowStackView.axis = .horizontal + rowStackView.alignment = .center + rowStackView.spacing = 8 + return rowStackView + } + + private func makeKeyView(for content: KeyContent) -> UIView { + let keyContainerView = UIView() + keyContainerView.translatesAutoresizingMaskIntoConstraints = false + keyContainerView.backgroundColor = .clear + + NSLayoutConstraint.activate([ + keyContainerView.widthAnchor.constraint(equalToConstant: 50), + keyContainerView.heightAnchor.constraint(equalToConstant: PublicUtils.isIPhone ? 38 : 42) + ]) + + let borderView = UIView() + borderView.tag = Self.keyBorderViewTag + borderView.translatesAutoresizingMaskIntoConstraints = false + borderView.isUserInteractionEnabled = false + borderView.backgroundColor = .clear + borderView.layer.cornerRadius = 13 + borderView.layer.cornerCurve = .continuous + borderView.layer.borderWidth = 1 + borderView.layer.masksToBounds = true + keyContainerView.addSubview(borderView) + + let keyView = UIView() + keyView.tag = Self.keyContentViewTag + keyView.translatesAutoresizingMaskIntoConstraints = false + keyView.layer.cornerRadius = 12 + keyView.layer.cornerCurve = .continuous + keyView.layer.masksToBounds = true + keyContainerView.addSubview(keyView) + + NSLayoutConstraint.activate([ + borderView.leadingAnchor.constraint(equalTo: keyContainerView.leadingAnchor), + borderView.trailingAnchor.constraint(equalTo: keyContainerView.trailingAnchor), + borderView.topAnchor.constraint(equalTo: keyContainerView.topAnchor), + borderView.bottomAnchor.constraint(equalTo: keyContainerView.bottomAnchor), + + keyView.leadingAnchor.constraint(equalTo: keyContainerView.leadingAnchor, constant: 1), + keyView.trailingAnchor.constraint(equalTo: keyContainerView.trailingAnchor, constant: -1), + keyView.topAnchor.constraint(equalTo: keyContainerView.topAnchor, constant: 1), + keyView.bottomAnchor.constraint(equalTo: keyContainerView.bottomAnchor, constant: -1) + ]) + + switch content { + case .text(let text): + let keyLabel = InsetLabel() + keyLabel.contentInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: 6) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + keyLabel.text = text + keyLabel.textAlignment = .center + keyLabel.font = UIFont.roundedSystemFont(ofSize: 12, weight: .medium) + keyLabel.adjustsFontSizeToFitWidth = true + keyLabel.minimumScaleFactor = 0.2 + keyLabel.numberOfLines = 1 + keyLabel.lineBreakMode = .byClipping + keyView.addSubview(keyLabel) + NSLayoutConstraint.activate([ + keyLabel.leadingAnchor.constraint(equalTo: keyView.leadingAnchor), + keyLabel.trailingAnchor.constraint(equalTo: keyView.trailingAnchor), + keyLabel.topAnchor.constraint(equalTo: keyView.topAnchor), + keyLabel.bottomAnchor.constraint(equalTo: keyView.bottomAnchor) + ]) + case .symbol(let symbolName): + let imageView = UIImageView(image: UIImage(systemName: symbolName)) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium) + keyView.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.centerXAnchor.constraint(equalTo: keyView.centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: keyView.centerYAnchor), + imageView.widthAnchor.constraint(lessThanOrEqualTo: keyView.widthAnchor, multiplier: 0.58), + imageView.heightAnchor.constraint(lessThanOrEqualTo: keyView.heightAnchor, multiplier: 0.58) + ]) + } + + return keyContainerView + } + + private func observeThemeChanges() { + themeObserver = NotificationCenter.default.addObserver( + forName: Notification.Name(ThemeManager.ThemeDidChangeNotification), + object: nil, + queue: .main + ) { [weak self] _ in + self?.updateTheme() + } + } + + private static func keyWindow() -> UIWindow? { + let sceneWindows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + return sceneWindows.first(where: { $0.isKeyWindow }) + ?? sceneWindows.first + ?? UIApplication.shared.windows.first(where: { $0.isKeyWindow }) + ?? UIApplication.shared.windows.first + } + + private static func isControlActive(_ control: ControllerElement) -> Bool { + !relatedControls(for: control).isDisjoint(with: activeControls) + } + + private static func requestActionStateEnd(for control: ControllerElement) { + pendingActionStateWorkItems[control]?.cancel() + + let now = CACurrentMediaTime() + let remainingDuration = max((actionStateMinimumEndTimes[control] ?? now) - now, 0) + guard remainingDuration > 0 else { + finishActionStateEnd(for: control) + return + } + + let workItem = DispatchWorkItem { + finishActionStateEnd(for: control) + } + pendingActionStateWorkItems[control] = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + remainingDuration, execute: workItem) + } + + private static func finishActionStateEnd(for control: ControllerElement) { + pendingActionStateWorkItems[control]?.cancel() + pendingActionStateWorkItems[control] = nil + actionStateMinimumEndTimes[control] = nil + + guard activeControls.remove(control) != nil else { return } + currentHud?.refreshActionStates() + } + + private static func requestHudDetachKeepingMinimumActionDuration() { + pendingClearHudWorkItem?.cancel() + + let remainingDuration = maximumRemainingActionStateDuration() + guard remainingDuration > 0 else { + finishHudDetach() + return + } + + let workItem = DispatchWorkItem { + finishHudDetach() + } + pendingClearHudWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + remainingDuration, execute: workItem) + } + + private static func finishHudDetach() { + pendingClearHudWorkItem?.cancel() + pendingClearHudWorkItem = nil + pendingActionStateWorkItems.values.forEach { $0.cancel() } + pendingActionStateWorkItems.removeAll() + actionStateMinimumEndTimes.removeAll() + activeControls.removeAll() + currentHud?.detachFromWindow() + currentHud = nil + } + + private static func maximumRemainingActionStateDuration() -> CFTimeInterval { + let now = CACurrentMediaTime() + return activeControls.reduce(0) { partialResult, control in + max(partialResult, max((actionStateMinimumEndTimes[control] ?? now) - now, 0)) + } + } + + private static func relatedControls(for control: ControllerElement) -> Set { + switch control { + case .leftStick, .leftStickX, .leftStickY: + return [.leftStick, .leftStickX, .leftStickY] + case .rightStick, .rightStickX, .rightStickY: + return [.rightStick, .rightStickX, .rightStickY] + default: + return [control] + } + } +} + +final class InsetLabel: UILabel { + var contentInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 4) + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: contentInsets)) + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize( + width: size.width + contentInsets.left + contentInsets.right, + height: size.height + contentInsets.top + contentInsets.bottom + ) + } +} diff --git a/VoidLink/HostCardView.swift b/VoidLink/HostCardView.swift index 47a4c61a..64b69970 100644 --- a/VoidLink/HostCardView.swift +++ b/VoidLink/HostCardView.swift @@ -107,6 +107,7 @@ class HostCardView: UIView { } @objc func appButtonTapped() { + guard host?.pairState == pairStatePaired, host?.state == .online, self.parentViewController?.hasNoPresentedVC == true else { return } if longPressFired { longPressFired = false return @@ -120,6 +121,7 @@ class HostCardView: UIView { } @objc func launchButtonTapped() { + guard host?.pairState == pairStatePaired, host?.state == .online, self.parentViewController?.hasNoPresentedVC == true else { return } if longPressFired { longPressFired = false return @@ -133,6 +135,7 @@ class HostCardView: UIView { } @objc func wakeupButtonTapped() { + guard host?.state != .online, self.parentViewController?.hasNoPresentedVC == true else { return } if longPressFired { longPressFired = false return @@ -146,6 +149,7 @@ class HostCardView: UIView { } @objc func pairButtonTapped() { + guard !pairButton.isHidden, self.parentViewController?.hasNoPresentedVC == true else { return } if longPressFired { longPressFired = false return @@ -442,7 +446,6 @@ class HostCardView: UIView { } func tintAdjustmentModeDidChange() { - NSLog("tintChanged........") tintAdjustmentMode = .normal } @@ -476,7 +479,10 @@ class HostCardView: UIView { return } - updateContents(for: host) + DispatchQueue.main.asyncAfter(deadline: .now()+0.36) { [weak self] in + self?.updateContents(for: self?.host) + } + perform(#selector(updateLoop), with: self, afterDelay: Self.refreshCycle) } diff --git a/VoidLink/Images.xcassets/circle.lefthalf.filled.imageset/Contents.json b/VoidLink/Images.xcassets/circle.lefthalf.filled.imageset/Contents.json new file mode 100644 index 00000000..46de852f --- /dev/null +++ b/VoidLink/Images.xcassets/circle.lefthalf.filled.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "circle.lefthalf.filled.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/VoidLink/Images.xcassets/circle.lefthalf.filled.imageset/circle.lefthalf.filled.svg b/VoidLink/Images.xcassets/circle.lefthalf.filled.imageset/circle.lefthalf.filled.svg new file mode 100644 index 00000000..38c7bb6e --- /dev/null +++ b/VoidLink/Images.xcassets/circle.lefthalf.filled.imageset/circle.lefthalf.filled.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/VoidLink/Images.xcassets/controllerMouse.imageset/Contents.json b/VoidLink/Images.xcassets/controllerMouse.imageset/Contents.json new file mode 100644 index 00000000..606ec2aa --- /dev/null +++ b/VoidLink/Images.xcassets/controllerMouse.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "controllerMouse.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/VoidLink/Images.xcassets/controllerMouse.imageset/controllerMouse.svg b/VoidLink/Images.xcassets/controllerMouse.imageset/controllerMouse.svg new file mode 100644 index 00000000..f595759e --- /dev/null +++ b/VoidLink/Images.xcassets/controllerMouse.imageset/controllerMouse.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/VoidLink/Images.xcassets/disableControllerMouse.imageset/Contents.json b/VoidLink/Images.xcassets/disableControllerMouse.imageset/Contents.json new file mode 100644 index 00000000..b46a6bab --- /dev/null +++ b/VoidLink/Images.xcassets/disableControllerMouse.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "disableControllerMouse.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/VoidLink/Images.xcassets/disableControllerMouse.imageset/disableControllerMouse.svg b/VoidLink/Images.xcassets/disableControllerMouse.imageset/disableControllerMouse.svg new file mode 100644 index 00000000..d64f47d3 --- /dev/null +++ b/VoidLink/Images.xcassets/disableControllerMouse.imageset/disableControllerMouse.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/VoidLink/Images.xcassets/macwindow.and.pointer.arrow.imageset/Contents.json b/VoidLink/Images.xcassets/macwindow.and.pointer.arrow.imageset/Contents.json new file mode 100644 index 00000000..b4bc44ec --- /dev/null +++ b/VoidLink/Images.xcassets/macwindow.and.pointer.arrow.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "macwindow.and.pointer.arrow.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/VoidLink/Images.xcassets/macwindow.and.pointer.arrow.imageset/macwindow.and.pointer.arrow.svg b/VoidLink/Images.xcassets/macwindow.and.pointer.arrow.imageset/macwindow.and.pointer.arrow.svg new file mode 100644 index 00000000..8d9811ed --- /dev/null +++ b/VoidLink/Images.xcassets/macwindow.and.pointer.arrow.imageset/macwindow.and.pointer.arrow.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/VoidLink/Images.xcassets/rectangle.portrait.and.arrow.right.imageset/Contents.json b/VoidLink/Images.xcassets/rectangle.portrait.and.arrow.right.imageset/Contents.json new file mode 100644 index 00000000..4e323904 --- /dev/null +++ b/VoidLink/Images.xcassets/rectangle.portrait.and.arrow.right.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "rectangle.portrait.and.arrow.right.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/VoidLink/Images.xcassets/rectangle.portrait.and.arrow.right.imageset/rectangle.portrait.and.arrow.right.svg b/VoidLink/Images.xcassets/rectangle.portrait.and.arrow.right.imageset/rectangle.portrait.and.arrow.right.svg new file mode 100644 index 00000000..1650ca2a --- /dev/null +++ b/VoidLink/Images.xcassets/rectangle.portrait.and.arrow.right.imageset/rectangle.portrait.and.arrow.right.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/VoidLink/Input/AbsoluteTouchHandler.h b/VoidLink/Input/AbsoluteTouchHandler.h index fe6c3010..59602c0f 100644 --- a/VoidLink/Input/AbsoluteTouchHandler.h +++ b/VoidLink/Input/AbsoluteTouchHandler.h @@ -7,6 +7,7 @@ // #import "StreamView.h" +#import "TemporarySettings.h" NS_ASSUME_NONNULL_BEGIN diff --git a/VoidLink/Input/ControllerNavigator.swift b/VoidLink/Input/ControllerNavigator.swift index 5a559d33..b08bd4eb 100644 --- a/VoidLink/Input/ControllerNavigator.swift +++ b/VoidLink/Input/ControllerNavigator.swift @@ -8,164 +8,1987 @@ import Foundation import GameController +import ObjectiveC +import UIKit + +final class ControllerNavigationElement: NSObject { + var control:ControllerElement + var action:String + var isInAction:Bool = false + init(control: ControllerElement, action: String){ + self.control = control + self.action = action + } +} + +private var controllerNavigationSelectedIndexPathKey: UInt8 = 0 +private var controllerNavigationPersistTokenKey: UInt8 = 0 +private let settingsControllerNavigationHighlightedIdentifierKey = "SettingsControllerNavigationHighlightedIdentifier" +private let hostControllerNavigationHighlightedUUIDKey = "HostControllerNavigationHighlightedUUID" + +@available(iOS 13.0, *) +private final class ControllerMouseDisplayLinkTarget: NSObject { + @objc func displayLinkDidFire() { + ControllerNavigator.controllerMouseDisplayLinkDidFire() + } +} @available(iOS 13.0, *) @objc protocol ControllerNavigatorRadialMenuDelegate: AnyObject { - func controllerNavigatorDidSelectSettings() - func controllerNavigatorDidSelectHostView() - func controllerNavigatorDidSelectGameProfiles() - func controllerNavigatorDidSelectExit() - func isStreaming() -> Bool - func isInAppView() -> Bool + func controllerNavigatorDidSelect(item: RadialMenuItem) } @available(iOS 13.0, *) @objc protocol ControllerUINavigationDelegate: AnyObject { - func nextItemForControllerNavigator() - func previousItemForControllerNavigator() + func getNavigationElements() -> [ControllerNavigationElement] + func navigateByController(forward: Bool) + func navigateByController(downward: Bool) + func uiWidgetActionForControllerNavigator(forward: Bool, from navigation:ControllerNavigationElement) + func uiButtonActionForControllerNavigator(pressed: Bool, from navigation:ControllerNavigationElement) + func persistControllerNavigationHighlight() + func restoreControllerNavigationHighlight() + func restoreControllerNavigationHighlightAfterSettingsModeSwitch() } @available(iOS 13.0, *) final class ControllerNavigator: NSObject { typealias Handler = (_ buttonDict: NSDictionary, _ gamepad: GCExtendedGamepad, _ element: GCControllerElement) -> Void - private static var connectObserver: NSObjectProtocol? - private static var disconnectObserver: NSObjectProtocol? private static weak var listeningController: GCController? - private static weak var radialMenuDelegate: ControllerNavigatorRadialMenuDelegate? - private static weak var uiNavigationDelegate: ControllerUINavigationDelegate? + static weak var radialMenuDelegate: ControllerNavigatorRadialMenuDelegate? + @objc static weak var uiNavigationDelegate: ControllerUINavigationDelegate? + private static weak var previousUINavigationDelegate: ControllerUINavigationDelegate? private static var swapABXY = false - private static var radialMenuView: RadialMenuOverlayView? - private static var localRadialMenuButton: ControllerButton = .leftShoulder - private static var localRadialMenuButtonFlag: Bool = false + @objc static var radialMenuView: RadialMenuOverlayView? + @objc static var stickReleasedInRadialMenu: Bool = false + @objc static var radialMenuButton: ControllerElement = .leftShoulder + @objc static var radialMenuButtonPool: NSMutableSet { + let pool:NSMutableSet = NSMutableSet() + pool.add(ControllerElement.leftTrigger.rawValue) + pool.add(ControllerElement.rightTrigger.rawValue) + pool.add(ControllerElement.leftShoulder.rawValue) + pool.add(ControllerElement.rightShoulder.rawValue) + pool.add(ControllerElement.select.rawValue) + pool.add(ControllerElement.start.rawValue) + pool.add(ControllerElement.special.rawValue) + pool.add(ControllerElement.paddle1.rawValue) + pool.add(ControllerElement.paddle2.rawValue) + pool.add(ControllerElement.paddle3.rawValue) + pool.add(ControllerElement.paddle4.rawValue) + return pool + } + + @objc static var radialMenuButtonPosition: ControllerElementPosition { + var position:ControllerElementPosition = .undefined + if radialMenuButton.position != .middle && radialMenuButton.position != .undefined {position = radialMenuButton.position} + else { + if radialMenuButton == localRadialMenuButton {position = customPositionForLocalRadialMenuButton} + if radialMenuButton == streamingRadialMenuButton {position = customPositionForStreamingRadialMenuButton} + } + return position + } + + + @objc static var enabled: Bool = true { + didSet{ + if enabled { + if let settingViewVC = uiNavigationDelegate as? SettingsViewController { + settingViewVC.highlightViewForControllerNavigator(by: "controllerNavigationStack") + GamepadNavigationIllustrationHud.updateHud() + } + } + else { + stop() + } + } + } + + @objc static var localRadialMenuButton: ControllerElement = .leftShoulder + @objc static var customPositionForLocalRadialMenuButton: ControllerElementPosition = .left + @objc static var streamingRadialMenuButton: ControllerElement = .leftShoulder + @objc static var customPositionForStreamingRadialMenuButton: ControllerElementPosition = .left + @objc static var streamingRadialMenuDelay: TimeInterval = 0 + + private static let controllerMouseDisplayLinkTarget = ControllerMouseDisplayLinkTarget() + private static var controllerMouseDisplayLink: CADisplayLink? + @objc static var controllerMouseStick: ControllerElement = .rightStick + @objc static var controllerMouseLeftButton: ControllerElement = .dpadRight + @objc static var controllerMouseRightButton: ControllerElement = .dpadDown + @objc static var controllerMouseVelocity: CGFloat = 11.3 + @objc static var controllerMouseExpo: CGFloat = 1.6 + private static var controllerMouseInputX: CGFloat = 0 + private static var controllerMouseInputY: CGFloat = 0 + private static var controllerMouseWheelInputX: CGFloat = 0 + private static var controllerMouseWheelInputY: CGFloat = 0 + private static var controllerMouseFrameRate = 60 + @objc static var controllerMouseEnabled: Bool { + return radialMenuState == .mouseModeEnabled + } + private static var controllerMouseNavigationElements: [ControllerNavigationElement] { + var elements = [ControllerNavigationElement]() + elements.append(ControllerNavigationElement(control:ControllerNavigator.controllerMouseStick, action: "moveCursor")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.controllerMouseLeftButton, action: "leftButton")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.controllerMouseRightButton, action: "rightButton")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.controllerMouseStick.position == .left ? .rightStick : .leftStick, action: "verticalHorizontalScroll")) + return elements + } + + static var settingsFavoriteReorderActive: Bool = false + static var settingsReadTipPendingToken = 0 + static var settingsReadTipPendingWorkItem: DispatchWorkItem? + static var settingsReadTipPressedAgain = false + static var settingsReadTipSuppressNextRelease = false + + static var navigationTimer: SafeTimer? + @objc static weak var controllerNavigationHighlightedView: UIView? + private static let installAlertControllerNavigationHook: Void = { + UIAlertController.installControllerNavigationDelegateHook() + }() @objc static func setRadialMenuDelegate(_ delegate: ControllerNavigatorRadialMenuDelegate?) { + _ = installAlertControllerNavigationHook radialMenuDelegate = delegate } @objc static func setUINavigationDelegate(_ delegate: ControllerUINavigationDelegate?) { + _ = installAlertControllerNavigationHook + if let currentDelegate = uiNavigationDelegate, + let nextDelegate = delegate, + currentDelegate !== nextDelegate, !(currentDelegate is UIAlertController) { + previousUINavigationDelegate = currentDelegate + } + uiNavigationDelegate = delegate + updateUINavigationDelegateState() } + @objc static func setStreamFrameVCAsUINavigationDelegate() { + if let delegate: ControllerUINavigationDelegate? = StreamFrameViewController.sharedInstance() { + _ = installAlertControllerNavigationHook + if let currentDelegate = uiNavigationDelegate, + let nextDelegate = delegate, + currentDelegate !== nextDelegate, !(currentDelegate is UIAlertController) { + previousUINavigationDelegate = currentDelegate + } + uiNavigationDelegate = delegate + listenToRadialMenuButton() + GamepadNavigationIllustrationHud.updateHud() + } + } + + @objc static func restorePreviousUINavigationDelegate(ifCurrentDelegateIs delegate: ControllerUINavigationDelegate) { + guard uiNavigationDelegate === delegate else { return } + /* + if previousUINavigationDelegate is StreamFrameViewController { + ControllerSupport.sharedInstance().reinitiatePrimaryController() + return + } */ + + + uiNavigationDelegate = previousUINavigationDelegate + // previousUINavigationDelegate = nil + updateUINavigationDelegateState() + } + + static func updateUINavigationDelegateState() { + if uiNavigationDelegate is StreamFrameViewController { + if radialMenuState == .mouseModeEnabled { + startControllerMouse() + // GamepadNavigationIllustrationHud.updateNavigationElements(controllerMouseNavigationElements) + return + } + ControllerSupport.sharedInstance().reinitiatePrimaryController() + GamepadNavigationIllustrationHud.updateHud() + return; + } + + restartListening() + GamepadNavigationIllustrationHud.updateHud() + } + + @objc static func restoreUINavigationHighlight() { + guard enabled else {return} + uiNavigationDelegate?.restoreControllerNavigationHighlight() + } + + @objc static func persistUINavigationHighlight() { + guard enabled else {return} + uiNavigationDelegate?.persistControllerNavigationHighlight() + if previousUINavigationDelegate !== uiNavigationDelegate { + previousUINavigationDelegate?.persistControllerNavigationHighlight() + } + } + + @objc static func restoreSettingsModeSwitchHighlight() { + guard enabled else {return} + uiNavigationDelegate?.restoreControllerNavigationHighlightAfterSettingsModeSwitch() + } + + @objc static func updateHudForCustomRadialMenuButtonPosition() { + var elements: [ControllerNavigationElement] = [] + elements.append(ControllerNavigationElement(control: .dpadLeft, action: "Left")) + elements.append(ControllerNavigationElement(control: .dpadRight, action: "Right")) + elements.append(ControllerNavigationElement(control: .dpadUp, action: "Centered (PlayStation)")) + GamepadNavigationIllustrationHud.updateNavigationElements(elements, forceDisplay: true) + } + + + /// Controller Mouse + + @objc static func startControllerMouse() { + controllerMouseInputX = 0 + controllerMouseInputY = 0 + startControllerMouseDisplayLink(frameRate: controllerMouseFrameRate) + ControllerUtil.stopListeningPrimaryController() + listenToControllerMouse() + DispatchQueue.main.async { + GamepadNavigationIllustrationHud.updateNavigationElements(controllerMouseNavigationElements) + DispatchQueue.main.asyncAfter(deadline: .now()+2) { + GamepadNavigationIllustrationHud.clearHud() + } + } + } + + @objc static func configureControllerMouse(with settings: TemporarySettings) { + // controllerMouseEnabledFlag = false + controllerMouseStick = ControllerElement(rawValue: settings.controllerMouseStick.int32Value) ?? .rightStick + controllerMouseLeftButton = ControllerElement(rawValue: settings.controllerMouseLeftButton.int32Value) ?? .dpadRight + controllerMouseRightButton = ControllerElement(rawValue: settings.controllerMouseRightButton.int32Value) ?? .dpadDown + controllerMouseExpo = CGFloat(settings.controllerMouseExpo.floatValue) + controllerMouseFrameRate = max(settings.framerate.intValue, 1) + controllerMouseVelocity = PublicUtils.controllerMouseVelocity( + pointerVelocity: CGFloat(settings.controllerMousePointerVelocity.floatValue), + frameRate: CGFloat(controllerMouseFrameRate) + ) + } + + /* + @objc static func stopControllerMouse() { + controllerMouseEnabledFlag = false + stopControllerMouseDisplayLink() + } + + @objc static func setControllerMouseEnabled(_ enabled: Bool) { + controllerMouseEnabledFlag = mapControllerToMouse && enabled + if !controllerMouseEnabledFlag { + } + } + + @objc static func isControllerMouseEnabled() -> Bool { + return controllerMouseEnabledFlag + } + */ + + + fileprivate static func controllerMouseDisplayLinkDidFire() { + guard radialMenuState == .mouseModeEnabled, + let displayLink = controllerMouseDisplayLink else { + return + } + + // print("controllerMouseDisplayLinkDidFire \(CACurrentMediaTime())") + + let delay = 0.5 / Double(max(displayLink.preferredFramesPerSecond, 1)) + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay) { + guard radialMenuState == .mouseModeEnabled else { return } + let moveVector = PublicUtils.controllerMouseMoveVector( + stickX: controllerMouseInputX, + stickY: controllerMouseInputY, + velocity: controllerMouseVelocity, + expo: controllerMouseExpo + ) + if moveVector.dx != 0 || moveVector.dy != 0 { + LiSendMouseMoveEvent(clampedInt16(moveVector.dx), clampedInt16(moveVector.dy)) + } + + let scrollVector = PublicUtils.controllerMouseScrollVector( + stickX: controllerMouseWheelInputX, + stickY: controllerMouseWheelInputY, + multiplier: 15 + ) + if abs(scrollVector.dy) > abs(scrollVector.dx) { + LiSendHighResScrollEvent(clampedInt16(scrollVector.dy)) + } + else { + LiSendHighResHScrollEvent(clampedInt16(scrollVector.dx)) + } + } + } + + private static func startControllerMouseDisplayLink(frameRate: Int) { + stopControllerMouseDisplayLink() + + let displayLink = CADisplayLink( + target: controllerMouseDisplayLinkTarget, + selector: #selector(ControllerMouseDisplayLinkTarget.displayLinkDidFire) + ) + if #available(iOS 15.0, tvOS 15.0, *) { + displayLink.preferredFrameRateRange = CAFrameRateRange( + minimum: Float(frameRate), + maximum: Float(frameRate), + preferred: Float(frameRate) + ) + } else { + displayLink.preferredFramesPerSecond = frameRate + } + displayLink.add(to: .main, forMode: .common) + controllerMouseDisplayLink = displayLink + } + + private static func stopControllerMouseDisplayLink() { + controllerMouseInputX = 0 + controllerMouseInputY = 0 + controllerMouseDisplayLink?.invalidate() + controllerMouseDisplayLink = nil + } + + + private static func swappedFaceButtonIfNeeded(_ button: ControllerElement, swapABXY: Bool) -> ControllerElement { + guard swapABXY else { return button } + switch button { + case .a: + return .b + case .b: + return .a + case .x: + return .y + case .y: + return .x + default: + return button + } + } + + private static func clampedInt16(_ value: CGFloat) -> Int16 { + let roundedValue = value.rounded() + if roundedValue > CGFloat(Int16.max) { + return Int16.max + } + if roundedValue < CGFloat(Int16.min) { + return Int16.min + } + return Int16(roundedValue) + } + + /* @objc static func start(swapABXY: Bool = false) { Self.swapABXY = swapABXY installControllerObserversIfNeeded() listenFirstAvailableController() + + } + +*/ + private static func navigateContinuously(forward: Bool) { + navigationTimer?.clean() + navigationTimer = SafeTimer(interval: 0.23) { + uiNavigationDelegate?.navigateByController(forward: forward) + } + navigationTimer?.restart() + } + + private static func navigateContinuously(downward: Bool) { + navigationTimer?.clean() + navigationTimer = SafeTimer(interval: 0.23) { + uiNavigationDelegate?.navigateByController(downward: downward) + } + navigationTimer?.restart() + } + + @objc static func start() { + guard let controller = ControllerUtil.primaryGCController, controller.extendedGamepad != nil else { return } + restartListening() + GamepadNavigationIllustrationHud.updateHud() } @objc static func stop() { - stopListeningCurrentController() - removeControllerObservers() + ControllerUtil.stopListeningPrimaryController(stopListenToRadialMenuButton: true) + radialMenuView?.dismiss() + radialMenuView = nil + GamepadNavigationIllustrationHud.clearHud() + clearNavigationHighlights() } - static func listen(controller: GCController, swapABXY: Bool = false) { - guard controller.extendedGamepad != nil else { return } - listeningController = controller - ControllerUtil.listen(controller: controller, swapABXY: swapABXY) { buttonDict, gamepad, element in - - if let gcButton = buttonDict[localRadialMenuButton.rawValue] as? GCControllerButtonInput { - if gcButton.isPressed != localRadialMenuButtonFlag { - if gcButton.isPressed, radialMenuView == nil { - RadialMenuOverlayView.menuSectors.removeAll() - RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Settings Menu".localized, subtitle: "", systemImageName: "sidebar.left", item: .settings)) - if radialMenuDelegate?.isInAppView() == true, !(radialMenuDelegate?.isStreaming() ?? false) {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Host View".localized, subtitle: "", systemImageName: PublicUtils.liquidGlassEnabled ? "macwindow.on.rectangle" : "tv", item: .hostView))} - if radialMenuDelegate?.isStreaming() == true {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Disconnect".localized, subtitle: "", systemImageName: PublicUtils.disconnectSymbol(), item: .exit))} - RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Game Profiles".localized, subtitle: "", systemImageName:PublicUtils.iOS18Available ? "gamecontroller.circle" : "gamecontroller.fill", item: .gameProfiles)) - - radialMenuView = RadialMenuOverlayView.presentInKeyWindow() - } - if !gcButton.isPressed { - radialMenuView?.dismiss() - radialMenuView = nil - } - } - localRadialMenuButtonFlag = gcButton.isPressed - } - - if let selectedItem = radialMenuView?.updateSelection( - xOffset: gamepad.rightThumbstick.xAxis.value, - yOffset: gamepad.rightThumbstick.yAxis.value - ) { - performRadialMenuAction(selectedItem) + @objc static func clearNavigationHighlights() { + PublicUtils.runOnMain { + if let mainFrameVC = radialMenuDelegate as? MainFrameViewController { + mainFrameVC.settingsViewController?.clearControllerNavigationHighlightForControllerNavigator() + mainFrameVC.hostCollectionVC?.clearCollectionControllerNavigationHighlightForControllerNavigator() + } else { + (uiNavigationDelegate as? SettingsViewController)?.clearControllerNavigationHighlightForControllerNavigator() + (uiNavigationDelegate as? UICollectionViewController)?.clearCollectionControllerNavigationHighlightForControllerNavigator() } + + controllerNavigationHighlightedView = nil } } - - private static func installControllerObserversIfNeeded() { - guard connectObserver == nil, disconnectObserver == nil else { return } - - connectObserver = NotificationCenter.default.addObserver( - forName: .GCControllerDidConnect, - object: nil, - queue: .main - ) { notification in - guard (notification.object as? GCController)?.extendedGamepad != nil else { return } - listenFirstAvailableController() - } - - disconnectObserver = NotificationCenter.default.addObserver( - forName: .GCControllerDidDisconnect, - object: nil, - queue: .main - ) { notification in - listenFirstAvailableController() - } - } - - private static func removeControllerObservers() { - if let connectObserver { - NotificationCenter.default.removeObserver(connectObserver) - self.connectObserver = nil - } - - if let disconnectObserver { - NotificationCenter.default.removeObserver(disconnectObserver) - self.disconnectObserver = nil - } - } - - private static func listenFirstAvailableController() { - guard let controller = GCController.controllers().first(where: { $0.extendedGamepad != nil }) else { - stopListeningCurrentController() + + private static func performRadialMenuAction(_ item: RadialMenuItem) { + radialMenuDelegate?.controllerNavigatorDidSelect(item:item) + switch item { + case .gameProfiles, .addHost, .aboutView: radialMenuView?.dismiss() radialMenuView = nil + case .disconnect: + if radialMenuState == .disconnectAndQuit { + radialMenuView?.dismiss() + radialMenuView = nil + StreamFrameViewController.sharedInstance().returnToMainFrame() + break + } + else { + radialMenuState = .disconnectAndQuit + updateRadialMenu() + break + } + case .quitApp: + radialMenuView?.dismiss() + radialMenuView = nil + StreamFrameViewController.sharedInstance().disconnectAndQuitApp() + case .more: + radialMenuState = .moreOptions + updateRadialMenu() + case .mouse: + if radialMenuState != .mouseModeEnabled { + radialMenuState = .mouseModeEnabled + updateRadialMenu() + } + else { + stopControllerMouseDisplayLink() + radialMenuState = .main + updateRadialMenu() + } + case .theme: + let targetTheme: UIUserInterfaceStyle = ThemeManager.userInterfaceStyle() == .light ? .dark : .light + DispatchQueue.main.async { + ThemeManager.setUserInterfaceStyle(targetTheme) + if let mainFrameVC = radialMenuDelegate as? MainFrameViewController, let settingsViewVC = mainFrameVC.settingsViewController { + settingsViewVC.appThemeSelector.selectedSegmentIndex = targetTheme.rawValue + } + } + let dataMan = DataManager() + let settings = dataMan.retrieveSettings() + settings?.appTheme = NSNumber(value: targetTheme.rawValue) + dataMan.saveData() + case .navigationSettings: + DispatchQueue.main.async { + radialMenuView?.dismiss() + radialMenuView = nil + if let mainFrameVC = radialMenuDelegate as? MainFrameViewController { + mainFrameVC.expandSettingsView() + DispatchQueue.main.asyncAfter(deadline: .now()+0.3) { + if let settingViewVC = uiNavigationDelegate as? SettingsViewController { + settingViewVC.expandGamepadSection() + settingViewVC.highlightViewForControllerNavigator(by: "controllerNavigationStack") + } + } + } + } + case .exit: + exit(0) + default: + break + } + } + + @objc static func updateRadialMenu() { + controllerMouseInputX = 0 + controllerMouseInputY = 0 + navigationTimer?.clean() + ControllerUtil.stopListeningPrimaryController() + if let mainFrameVC = radialMenuDelegate as? MainFrameViewController { + let streamFrameVC = StreamFrameViewController.sharedInstance() + let showRadiaMenu = mainFrameVC.isStreaming() ? streamFrameVC?.hasNoPresentedVC == true : mainFrameVC.hasNoPresentedVC + if showRadiaMenu { + radialMenuView?.removeFromSuperview() + RadialMenuOverlayView.menuSectors.removeAll() + + if radialMenuState == .disconnectAndQuit { + RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Disconnect".localized, subtitle: "", symbol: PublicUtils.disconnectSymbol(), item: .disconnect)) + RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Quit App".localized, subtitle: "", symbol: PublicUtils.quitSymbol(), item: .quitApp)) + radialMenuView = RadialMenuOverlayView.presentInKeyWindow() + listenToRadialMenuStick() + return + } + + let navigationSettingsItem = RadialMenuSector(title: "Navigation Settings".localized, subtitle: "", symbol:"gear", item: .navigationSettings) + + if radialMenuState == .moreOptions { + if mainFrameVC.isStreaming() { + RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Game Profiles".localized, subtitle: "", symbol:PublicUtils.iOS18Available ? "gamecontroller.circle" : "gamecontroller.fill", item: .gameProfiles)) + RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "=shortcuts".localized, subtitle: "", symbol: "apple.terminal", item: .mouse)) + } + else { + if !mainFrameVC.isInAppView(), !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "About".localized, subtitle: "", symbol: "questionmark.circle", item: .aboutView))} + if !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Theme".localized, subtitle: "", symbol: "circle.lefthalf.filled", item: .theme))} + if !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.appendIfNotContains(navigationSettingsItem)} + if !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Exit".localized, subtitle: "", symbol: "rectangle.portrait.and.arrow.right", item: .exit))} + } + RadialMenuOverlayView.menuSectors.appendIfNotContains(navigationSettingsItem) + + radialMenuView = RadialMenuOverlayView.presentInKeyWindow() + listenToRadialMenuStick() + return + } + + let gameProfileItem = RadialMenuSector(title: "Game Profiles".localized, subtitle: "", symbol:PublicUtils.iOS18Available ? "gamecontroller.circle" : "gamecontroller.fill", item: .gameProfiles) + + RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Settings Menu".localized, subtitle: "", symbol: "sidebar.left", item: .settings)) + + if mainFrameVC.settingsViewExpanded { + if mainFrameVC.settingsViewController.currentSettingsMenuMode == .AllSettings { + RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Favorite Settings".localized, subtitle: "", symbol: "bookmark", item: .favoriteSettings)) + RadialMenuOverlayView.menuSectors.appendIfNotContains(gameProfileItem) + RadialMenuOverlayView.menuSectors.appendIfNotContains(navigationSettingsItem) + } + if mainFrameVC.settingsViewController.currentSettingsMenuMode == .FavoriteSettings { + RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "All Settings".localized, subtitle: "", symbol: "circle.grid.3x3", item: .allSettings)) + } + } + + if !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "More".localized, subtitle: "", symbol: "ellipsis.circle", item: .more))} + + if !mainFrameVC.isStreaming() || radialMenuState == .moreOptions { RadialMenuOverlayView.menuSectors.appendIfNotContains(gameProfileItem) + } + + if mainFrameVC.isInAppView(), !mainFrameVC.isStreaming(), !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Host View".localized, subtitle: "", symbol: PublicUtils.liquidGlassEnabled ? "macwindow.on.rectangle" : "tv", item: .hostView))} + + if !mainFrameVC.isInAppView(), !mainFrameVC.isStreaming(), !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Add Host".localized, subtitle: "", symbol: "plus.circle", item: .addHost))} + + if mainFrameVC.isStreaming(), !mainFrameVC.settingsExpandedInStreamView {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Controller Mouse".localized, subtitle: "", symbol: radialMenuState == .mouseModeEnabled ? "disableControllerMouse" : "controllerMouse", item: .mouse))} + if mainFrameVC.isStreaming(), !mainFrameVC.settingsExpandedInStreamView {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Disconnect".localized, subtitle: "", symbol: PublicUtils.disconnectSymbol(), item: .disconnect))} + // if mainFrameVC.isStreaming(), !mainFrameVC.settingsExpandedInStreamView {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Quit App".localized, subtitle: "", symbol: PublicUtils.quitSymbol(), item: .quitApp))} + radialMenuView = RadialMenuOverlayView.presentInKeyWindow() + } + } + listenToRadialMenuStick() + } + + private static func listenToRadialMenuStick() { + let radialMenuStick: ControllerElement = radialMenuButtonPosition == .right ? .leftStick : .rightStick + ControllerUtil.listenPrimaryControllerStick(radialMenuStick) { offsetVector in + GamepadNavigationIllustrationHud.updateActionState(for: radialMenuStick, isInAction: hypot(offsetVector.dx, offsetVector.dy) >= 0.1) + if let selectedItem = radialMenuView?.updateSelection( + xOffset: offsetVector.dx, + yOffset: offsetVector.dy + ) { + PublicUtils.runOnMain { + stickReleasedInRadialMenu = true + performRadialMenuAction(selectedItem) + } + } + } + } + + private static var radialMenuButtonPressed: Bool = false + private static var radialMenuDelayTimer: SafeTimer? + private static var radialMenuState: RadialMenuState = .main + private static func listenToRadialMenuButton() { + guard enabled else {return} + guard let mainFrameVC = radialMenuDelegate as? MainFrameViewController else {return} + radialMenuState = .main + radialMenuButton = mainFrameVC.isStreaming() ? streamingRadialMenuButton : localRadialMenuButton + ControllerUtil.listenPrimaryControllerButton(radialMenuButton) { pressed in + GamepadNavigationIllustrationHud.updateActionState(for: radialMenuButton, isInAction: pressed) + if pressed { + radialMenuButtonPressed = true + let delayRadialMenu = (mainFrameVC.isStreaming() && !mainFrameVC.settingsExpandedInStreamView) || + streamingRadialMenuDelay < 0.03 + streamingRadialMenuDelay = max(streamingRadialMenuDelay, 0.032) + + if delayRadialMenu { + radialMenuDelayTimer = SafeTimer(interval: streamingRadialMenuDelay) { + if radialMenuDelayTimer?.remainingMinimumRunCount == 2 {return} + if radialMenuButtonPressed { + DispatchQueue.main.async { + self.updateRadialMenu() + radialMenuDelayTimer?.clean() + } + } + } + radialMenuDelayTimer?.restart(minimumRunCount: 2) + } + else { + self.updateRadialMenu() + } + } + else { + radialMenuButtonPressed = false + radialMenuDelayTimer?.clean() + stickReleasedInRadialMenu = false + if radialMenuView == nil, uiNavigationDelegate is StreamFrameViewController { + ControllerSupport.sharedInstance().sendNavigationButtonPress() + return + } + radialMenuView?.dismiss() + radialMenuView = nil + ControllerNavigator.updateUINavigationDelegateState() + } + } + } + + @objc static func listenToControllerMouse() { + guard radialMenuState == .mouseModeEnabled else { return } + ControllerUtil.listenPrimaryControllerButton(controllerMouseLeftButton){ pressed in + GamepadNavigationIllustrationHud.updateActionState(for: controllerMouseLeftButton, isInAction: pressed) + LiSendMouseButtonEvent(CChar(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE), BUTTON_LEFT) + } + ControllerUtil.listenPrimaryControllerButton(controllerMouseRightButton){ pressed in + GamepadNavigationIllustrationHud.updateActionState(for: controllerMouseRightButton, isInAction: pressed) + LiSendMouseButtonEvent(CChar(pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE), BUTTON_RIGHT) + } + ControllerUtil.listenPrimaryControllerStick(controllerMouseStick){ offsetVector in + GamepadNavigationIllustrationHud.updateActionState(for: controllerMouseStick, isInAction: hypot(offsetVector.dx, offsetVector.dy) >= 0.1) + // print("performRadialMenuAction mouse \(offsetVector) \(CACurrentMediaTime())") + controllerMouseInputX = offsetVector.dx + controllerMouseInputY = offsetVector.dy + } + let wheelStick: ControllerElement = controllerMouseStick == .rightStick ? .leftStick : .rightStick + ControllerUtil.listenPrimaryControllerStick(wheelStick) { offsetVector in + GamepadNavigationIllustrationHud.updateActionState(for: wheelStick, isInAction: hypot(offsetVector.dx, offsetVector.dy) >= 0.1) + controllerMouseWheelInputX = offsetVector.dx + controllerMouseWheelInputY = offsetVector.dy + } + } + + @objc static func restartListening() { + guard enabled else { + ControllerUtil.stopListeningPrimaryController(stopListenToRadialMenuButton: true) + GamepadNavigationIllustrationHud.clearHud() + return + } + + ControllerUtil.stopListeningPrimaryController() + GamepadNavigationIllustrationHud.resetActionStates() + + guard let mainFrameVC = radialMenuDelegate as? MainFrameViewController else {return} + + listenToRadialMenuButton() + listenToRadialMenuStick() + + + + if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || mainFrameVC.settingsExpandedInStreamView { + let verticalNavigationAxis: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .leftStickY : .rightStickY + ControllerUtil.listenPrimaryControllerStickAxis(verticalNavigationAxis, threshold: 0.6) {state in + GamepadNavigationIllustrationHud.updateActionState(for: verticalNavigationAxis, isInAction: state != .orderedSame) + guard radialMenuView == nil else {return} + switch state { + case .orderedAscending: + if navigationTimer?.isRunning() != true { + if uiNavigationDelegate is SettingsViewController { + navigateContinuously(forward: true) + } + if uiNavigationDelegate is UICollectionViewController { + navigateContinuously(downward: true) + } + } + case .orderedDescending: + if navigationTimer?.isRunning() != true { + if uiNavigationDelegate is SettingsViewController { + navigateContinuously(forward: false) + } + if uiNavigationDelegate is UICollectionViewController { + navigateContinuously(downward: false) + } + } + case .orderedSame: + navigationTimer?.clean() + } + } + } + + if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() { + let horizontalNavigationAxis: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .leftStickX : .rightStickX + ControllerUtil.listenPrimaryControllerStickAxis(horizontalNavigationAxis, threshold: 0.6) {state in + GamepadNavigationIllustrationHud.updateActionState(for: horizontalNavigationAxis, isInAction: state != .orderedSame) + guard radialMenuView == nil else {return} + switch state { + case .orderedAscending: + if navigationTimer?.isRunning() != true { + if uiNavigationDelegate is UICollectionViewController { + navigateContinuously(forward: false) + } + } + case .orderedDescending: + if navigationTimer?.isRunning() != true { + if uiNavigationDelegate is UICollectionViewController { + navigateContinuously(forward: true) + } + } + case .orderedSame: + navigationTimer?.clean() + } + } + } + + if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || mainFrameVC.settingsExpandedInStreamView || uiNavigationDelegate is UIAlertController || uiNavigationDelegate is OSCProfilesTableViewController { + let navigations = uiNavigationDelegate.getNavigationElements() + let buttonNavigations = navigations.filter({$0.control.type == .button}) + // let stickNavigations = navigations.filter({$0.control.type == .stick || $0.control.type == .stickAxis}) + + var listenedControls = Set() + for buttonNavigation in buttonNavigations { + if buttonNavigation.control == radialMenuButton {continue} + if listenedControls.contains(buttonNavigation.control) {continue} + ControllerUtil.listenPrimaryControllerButton(buttonNavigation.control) {pressed in + buttonNavigation.isInAction = pressed + GamepadNavigationIllustrationHud.updateActionState(for: buttonNavigation.control, isInAction: pressed) + uiNavigationDelegate.uiButtonActionForControllerNavigator(pressed: pressed, from: buttonNavigation) + // print("buttonElement.control \(buttonElement.control.displayName) \(pressed)") + if !pressed { + navigationTimer?.clean() + } + } + listenedControls.insert(buttonNavigation.control) + } + } + + /* + ControllerUtil.listenPrimaryControllerStickAxis(.leftStickX, threshold: 0.8) {state in + guard uiNavigationDelegate != nil, radialMenuView == nil else {return} + switch state { + case .orderedAscending: + if navigationTimer?.isRunning() != true {uiNavigationDelegate?.uiWidgetActionForControllerNavigator(forward: false)} + case .orderedDescending: + if navigationTimer?.isRunning() != true {uiNavigationDelegate?.uiWidgetActionForControllerNavigator(forward: true)} + case .orderedSame: + navigationTimer?.clean() + } + } + */ + } + + +} + +@available(iOS 13.0, *) +extension SettingsViewController: ControllerUINavigationDelegate { + func persistControllerNavigationHighlight() { + guard let highlightedView = ControllerNavigator.controllerNavigationHighlightedView, + let identifier = controllerNavigationPersistenceIdentifier(for: highlightedView) else { + return + } + + UserDefaults.standard.set(identifier, forKey: settingsControllerNavigationHighlightedIdentifierKey) + } + + func restoreControllerNavigationHighlight() { + guard ControllerNavigator.enabled, ControllerUtil.primaryGCController != nil else {return} + PublicUtils.runOnMain { [weak self] in + self?.restoreControllerNavigationHighlightOnMain() + } + } + + func restoreControllerNavigationHighlightAfterSettingsModeSwitch() { + PublicUtils.runOnMain { [weak self] in + self?.restoreControllerNavigationHighlightAfterSettingsModeSwitchOnMain() + } + } + + func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) { + let isHighlightingUIStack = ControllerNavigator.controllerNavigationHighlightedView is UIStackView + + if navigation.action == "holdToReorder", isHighlightingUIStack { + handleControllerNavigationReorderHold(pressed: pressed) + return + } + + if isHighlightingUIStack, navigation.action == "readTip" || navigation.action == "doublePressToDelete" || navigation.action == "doublePressToAddFavorite" { + PublicUtils.runOnMain { [weak self] in + self?.handleControllerNavigationReadTip(pressed: pressed) + } + return + } + + if pressed { + switch navigation.action { + case "widgetOperationBackward": + self.uiWidgetActionForControllerNavigator(forward: false, from: navigation) + case "widgetOperationForward": + self.uiWidgetActionForControllerNavigator(forward: true, from: navigation) + default: + break + } + } + } + + func getNavigationElements() -> [ControllerNavigationElement] { + var elements: [ControllerNavigationElement] = [] + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .a, action: "readTip")) + if self.currentSettingsMenuMode == .AllSettings { + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .a, action: "doublePressToAddFavorite")) + } + if self.currentSettingsMenuMode == .FavoriteSettings { + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .a, action: "doublePressToDelete")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadDown : .y, action: "holdToReorder")) + } + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButton, action: "radialMenu")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .rightStickY : .leftStickY, action: "menuNavigation")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadLeft : .x, action: "widgetOperationBackward")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .b, action: "widgetOperationForward")) + return elements + } + + func navigateByController(forward:Bool) { + guard self.hasNoPresentedVC else {return} + if currentSettingsMenuMode == .FavoriteSettings, + ControllerNavigator.settingsFavoriteReorderActive { + moveHighlightedFavoriteSettingStack(by: forward ? 1 : -1) + return + } + + performControllerNavigationSelectionMove(by: forward ? 1 : -1) + } + + func navigateByController(downward:Bool) { + } + + func uiWidgetActionForControllerNavigator(forward: Bool, from navigation:ControllerNavigationElement) { + guard self.hasNoPresentedVC else {return} + + if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView, highlightedView is UIButton { + if let section = highlightedView.superview as? MenuSectionView { + section.toggleFold() + } + } + + if let stack = ControllerNavigator.controllerNavigationHighlightedView as? UIStackView { + for view in stack.arrangedSubviews { + if let selector = view as? UISegmentedControl { + guard selector.isEnabled else {continue} + var targetIndex:Int = 0 + repeat { + targetIndex = selector.nextIndex(forward: forward) + selector.selectedSegmentIndex = targetIndex + } while !selector.isEnabledForSegment(at: targetIndex) + selector.sendActions(for: .valueChanged) + } + if let uiSwitch = view as? UISwitch { + guard uiSwitch.isEnabled else {continue} + uiSwitch.isOn = !uiSwitch.isOn + uiSwitch.sendActions(for: .valueChanged) + } + if let slider = view as? UISlider { + guard slider.isEnabled else {continue} + ControllerNavigator.navigationTimer?.clean() + slider.step(forward: forward, visualStepRatio: 0.02) + DispatchQueue.main.asyncAfter(deadline: .now()+0.08) { + guard navigation.isInAction else { return } + ControllerNavigator.navigationTimer = SafeTimer(interval: 0.03) { + slider.step(forward: forward, visualStepRatio: 0.02) + } + ControllerNavigator.navigationTimer?.restart() + } + } + } + } + } + + private func performControllerNavigationSelectionMove(by offset: Int) { + PublicUtils.runOnMain { [weak self] in + self?.moveControllerNavigationSelection(by: offset) + } + } + + private func restoreControllerNavigationHighlightOnMain() { + if let persistedIdentifier = UserDefaults.standard.string(forKey: settingsControllerNavigationHighlightedIdentifierKey) { + highlightViewForControllerNavigator(by: persistedIdentifier) + } + } + + func highlightViewForControllerNavigator(by identifer: String?){ + let targets = controllerNavigationRestorableTargets() + let selectableTargets = targets.filter { isControllerNavigationSelectableTarget($0) } + guard !selectableTargets.isEmpty else { + clearControllerNavigationHighlight() return } - listen(controller: controller) + if let identifer = identifer, + let target = selectableTargets.first(where: { + controllerNavigationPersistenceIdentifier(for: $0) == identifer + }) { + highlightControllerNavigationView(target) + return + } + + highlightControllerNavigationView(selectableTargets[0]) } - private static func listen(controller: GCController) { - guard controller !== listeningController else { return } - stopListeningCurrentController() + private func restoreControllerNavigationHighlightAfterSettingsModeSwitchOnMain() { + let targets = controllerNavigationRestorableTargets() + let selectableTargets = targets.filter { isControllerNavigationSelectableTarget($0) } + guard !selectableTargets.isEmpty else { + clearControllerNavigationHighlight() + return + } + + if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView, + !isControllerNavigationSectionHeader(highlightedView), + let highlightedIdentifier = controllerNavigationPersistenceIdentifier(for: highlightedView), + let target = selectableTargets.first(where: { + !isControllerNavigationSectionHeader($0) && + controllerNavigationPersistenceIdentifier(for: $0) == highlightedIdentifier + }) { + highlightControllerNavigationView(target) + return + } + + highlightControllerNavigationView(selectableTargets[0]) + } + + private func controllerNavigationRestorableTargets() -> [UIView] { + var targets: [UIView] = [] + if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem, + let parentStack = parentStack { + targets.append(contentsOf: visibleFavoriteSettingStacks(in: parentStack)) + } + targets.append(contentsOf: controllerNavigationTargets(skippingDisabledStacks: false)) + + var seenIdentifiers = Set() + return targets.filter { target in + let identifier = ObjectIdentifier(target) + guard !seenIdentifiers.contains(identifier) else { return false } + seenIdentifiers.insert(identifier) + return true + } + } + + func controllerNavigationPersistenceIdentifier(for view: UIView) -> String? { + if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem, + let topLevelStack = topLevelFavoriteSettingStack(containing: view), + let identifier = topLevelStack.accessibilityIdentifier, + !identifier.isEmpty { + return identifier + } + + var currentView: UIView? = view + while let viewToCheck = currentView { + if let identifier = viewToCheck.accessibilityIdentifier, + !identifier.isEmpty { + return identifier + } + + if viewToCheck === parentStack { + return nil + } + + currentView = viewToCheck.superview + } + + return nil + } + + private func isControllerNavigationSectionHeader(_ view: UIView) -> Bool { + return view.accessibilityIdentifier?.hasPrefix("sectionHeader") == true + } + + private func handleControllerNavigationReorderHold(pressed: Bool) { + guard currentSettingsMenuMode == .FavoriteSettings else { + ControllerNavigator.settingsFavoriteReorderActive = false + return + } + + if pressed { + ControllerNavigator.settingsFavoriteReorderActive = true + return + } + + guard ControllerNavigator.settingsFavoriteReorderActive else { return } + ControllerNavigator.settingsFavoriteReorderActive = false + saveFavoriteSettingStackIdentifiers() + } + + private func handleControllerNavigationReadTip(pressed: Bool) { + if pressed { + handleControllerNavigationReadTipPressDown() + } else { + handleControllerNavigationReadTipRelease() + } + } + + private func handleControllerNavigationReadTipPressDown() { + if ControllerNavigator.settingsReadTipPendingWorkItem != nil { + ControllerNavigator.settingsReadTipPressedAgain = true + ControllerNavigator.settingsReadTipSuppressNextRelease = true + } + } + + private func handleControllerNavigationReadTipRelease() { + if ControllerNavigator.settingsReadTipSuppressNextRelease { + ControllerNavigator.settingsReadTipSuppressNextRelease = false + return + } + + cancelPendingControllerNavigationReadTip() + ControllerNavigator.settingsReadTipPendingToken += 1 + let token = ControllerNavigator.settingsReadTipPendingToken - if #available(iOS 14.0, *) { - for element in controller.physicalInputProfile.allElements { - element.preferredSystemGestureState = .disabled + let workItem = DispatchWorkItem { [weak self] in + guard ControllerNavigator.settingsReadTipPendingToken == token else { return } + + if ControllerNavigator.settingsReadTipPressedAgain { + + switch self?.currentSettingsMenuMode { + case .FavoriteSettings: + self?.removeHighlightedFavoriteSettingStack() + case .AllSettings: + if let highlitedStack = ControllerNavigator.controllerNavigationHighlightedView as? UIStackView { + self?.addSetting(toFavorite: highlitedStack) + DispatchQueue.main.async { + if AlertControllerUtil.autoCompletion {return} + AlertControllerUtil.autoCompletion = true + AlertControllerUtil.showAlert( + in: self, + title: LocalizationHelper.localizedString(forKey: ""), + message: "Setting added to favorite".localized, + withCancel: false, + buttonTitle: "", + countdown: 1, + completion: { + }) + } + } + default: + break + } + + // print("self?.cancelPendingControllerNavigationReadTip(resetSuppressNextRelease: false)"); + + } else { + self?.performControllerNavigationReadTip() + } + + + self?.cancelPendingControllerNavigationReadTip(resetSuppressNextRelease: false) + } + ControllerNavigator.settingsReadTipPendingWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) + } + + private func cancelPendingControllerNavigationReadTip(resetSuppressNextRelease: Bool = true) { + ControllerNavigator.settingsReadTipPendingWorkItem?.cancel() + ControllerNavigator.settingsReadTipPendingWorkItem = nil + ControllerNavigator.settingsReadTipPressedAgain = false + if resetSuppressNextRelease { + ControllerNavigator.settingsReadTipSuppressNextRelease = false + } + ControllerNavigator.settingsReadTipPendingToken += 1 + } + + private func performControllerNavigationReadTip() { + guard let stack = ControllerNavigator.controllerNavigationHighlightedView as? UIStackView, + stack.hasInfoTag || stack.isGameProfileSetting else { + return + } + + for view in stack.subviews.filter({ $0.accessibilityIdentifier == "infoButton" }) { + if let button = view as? UIButton { + button.sendActions(for: .touchUpInside) + } + } + } + + private func removeHighlightedFavoriteSettingStack() { + guard currentSettingsMenuMode == .FavoriteSettings, + let parentStack = parentStack, + let highlightedView = ControllerNavigator.controllerNavigationHighlightedView, + let stack = topLevelFavoriteSettingStack(containing: highlightedView) else { + return + } + + let visibleStacksBeforeRemoval = visibleFavoriteSettingStacks(in: parentStack) + guard let removedVisibleIndex = visibleStacksBeforeRemoval.firstIndex(where: { $0 === stack }) else { return } + let nextVisibleIndex = max(removedVisibleIndex - 1, 0) + + parentStack.removeArrangedSubview(stack) + stack.removeFromSuperview() + saveFavoriteSettingStackIdentifiers() + parentStack.setNeedsLayout() + + UIView.animate(withDuration: 0.12) { + parentStack.layoutIfNeeded() + } + + let visibleStacksAfterRemoval = visibleFavoriteSettingStacks(in: parentStack) + guard !visibleStacksAfterRemoval.isEmpty else { + clearControllerNavigationHighlight() + return + } + + let targetIndex = min(nextVisibleIndex, visibleStacksAfterRemoval.count - 1) + highlightControllerNavigationView(visibleStacksAfterRemoval[targetIndex]) + } + + private func visibleFavoriteSettingStacks(in parentStack: UIStackView) -> [UIStackView] { + return parentStack.arrangedSubviews.compactMap { $0 as? UIStackView }.filter { + isControllerNavigationVisible($0, within: parentStack) + } + } + + private func moveHighlightedFavoriteSettingStack(by offset: Int) { + PublicUtils.runOnMain { [weak self] in + self?.moveHighlightedFavoriteSettingStackOnMain(by: offset) + } + } + + private func moveHighlightedFavoriteSettingStackOnMain(by offset: Int) { + guard currentSettingsMenuMode == .FavoriteSettings, + let parentStack = parentStack, + let highlightedView = ControllerNavigator.controllerNavigationHighlightedView, + let stack = topLevelFavoriteSettingStack(containing: highlightedView), + isControllerNavigationVisible(stack, within: parentStack) else { + return + } + + let visibleStacks = parentStack.arrangedSubviews.compactMap { $0 as? UIStackView }.filter { + isControllerNavigationVisible($0, within: parentStack) + } + guard let currentVisibleIndex = visibleStacks.firstIndex(where: { $0 === stack }) else { return } + + let targetVisibleIndex = currentVisibleIndex + offset + guard visibleStacks.indices.contains(targetVisibleIndex) else { return } + + let targetStack = visibleStacks[targetVisibleIndex] + guard let targetArrangedIndex = parentStack.arrangedSubviews.firstIndex(where: { $0 === targetStack }) else { + return + } + + parentStack.removeArrangedSubview(stack) + parentStack.insertArrangedSubview(stack, at: targetArrangedIndex) + parentStack.setNeedsLayout() + + UIView.animate(withDuration: 0.12) { + parentStack.layoutIfNeeded() + } + + highlightControllerNavigationView(stack) + } + + private func topLevelFavoriteSettingStack(containing view: UIView) -> UIStackView? { + guard let parentStack = parentStack else { return nil } + + var currentView: UIView? = view + while let viewToCheck = currentView, viewToCheck !== parentStack { + if let stackView = viewToCheck as? UIStackView, + parentStack.arrangedSubviews.contains(where: { $0 === stackView }) { + return stackView + } + + currentView = viewToCheck.superview + } + + return nil + } + + private func moveControllerNavigationSelection(by offset: Int) { + let allTargets = controllerNavigationTargets(skippingDisabledStacks: false) + let targets = allTargets.filter { isControllerNavigationSelectableTarget($0) } + guard !targets.isEmpty else { + clearControllerNavigationHighlight() + return + } + + if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView, + let currentIndex = allTargets.firstIndex(where: { $0 === highlightedView }) { + var nextIndex = currentIndex + for _ in 0..= 0 ? 0 : targets.count - 1 + } + + highlightControllerNavigationView(targets[nextIndex]) } - private static func stopListeningCurrentController() { - listeningController?.extendedGamepad?.valueChangedHandler = nil - listeningController = nil + private func controllerNavigationTargets(skippingDisabledStacks: Bool = true) -> [UIView] { + guard let parentStack = parentStack else { return [] } + + switch currentSettingsMenuMode { + case .FavoriteSettings, .RemoveSettingItem: + return favoriteSettingsControllerNavigationTargets( + in: parentStack, + skippingDisabledStacks: skippingDisabledStacks + ) + case .AllSettings: + break + @unknown default: + break + } + + var targets: [UIView] = [] + for arrangedSubview in parentStack.arrangedSubviews { + guard let sectionView = arrangedSubview as? MenuSectionView else { continue } + guard isControllerNavigationVisible(sectionView, within: parentStack) else { continue } + + if let headerView = sectionView.headerView, + isControllerNavigationVisible(headerView, within: sectionView) { + targets.append(headerView) + } + + guard let rootStackView = sectionView.rootStackView else { continue } + guard isControllerNavigationVisible(rootStackView, within: sectionView) else { continue } + + for arrangedRootSubview in rootStackView.arrangedSubviews { + guard let stackView = arrangedRootSubview as? UIStackView else { continue } + targets.append(contentsOf: deepestVisibleControllerNavigationStacks(from: stackView, within: rootStackView, skippingDisabledStacks: skippingDisabledStacks)) + } + } + + return targets } - private static func performRadialMenuAction(_ item: RadialMenuItem) { - switch item { - case .settings: - radialMenuDelegate?.controllerNavigatorDidSelectSettings() + private func favoriteSettingsControllerNavigationTargets(in parentStack: UIStackView, skippingDisabledStacks: Bool) -> [UIView] { + var targets: [UIView] = [] + for arrangedSubview in parentStack.arrangedSubviews { + guard let stackView = arrangedSubview as? UIStackView else { continue } + targets.append(contentsOf: deepestVisibleControllerNavigationStacks( + from: stackView, + within: parentStack, + skippingDisabledStacks: skippingDisabledStacks + )) + } - case .hostView: - radialMenuDelegate?.controllerNavigatorDidSelectHostView() + return targets + } - case .gameProfiles: - radialMenuDelegate?.controllerNavigatorDidSelectGameProfiles() + private func deepestVisibleControllerNavigationStacks(from stackView: UIStackView, within rootStackView: UIStackView, skippingDisabledStacks: Bool) -> [UIStackView] { + guard isControllerNavigationVisible(stackView, within: rootStackView) else { return [] } - case .exit: - radialMenuDelegate?.controllerNavigatorDidSelectExit() + let childStackViews = stackView.arrangedSubviews.compactMap { $0 as? UIStackView } + let visibleChildStackViews = childStackViews.filter { + isControllerNavigationVisible($0, within: rootStackView) + } + + guard !visibleChildStackViews.isEmpty else { + if skippingDisabledStacks, hasDisabledArrangedSubview(in: stackView) { + return [] + } + + return [stackView] + } + + return visibleChildStackViews.flatMap { + deepestVisibleControllerNavigationStacks(from: $0, within: rootStackView, skippingDisabledStacks: skippingDisabledStacks) + } + } + + private func isControllerNavigationSelectableTarget(_ view: UIView) -> Bool { + if currentSettingsMenuMode == .FavoriteSettings || currentSettingsMenuMode == .RemoveSettingItem { + return true + } + + guard let stackView = view as? UIStackView else { return true } + return !hasDisabledArrangedSubview(in: stackView) + } + + private func hasDisabledArrangedSubview(in stackView: UIStackView) -> Bool { + for arrangedSubview in stackView.arrangedSubviews { + if let control = arrangedSubview as? UIControl, !control.isEnabled { + return true + } + + if let nestedStackView = arrangedSubview as? UIStackView, + hasDisabledArrangedSubview(in: nestedStackView) { + return true + } + } + + return false + } + + private func isControllerNavigationVisible(_ view: UIView, within ancestor: UIView) -> Bool { + var currentView: UIView? = view + while let viewToCheck = currentView { + if viewToCheck.isHidden || viewToCheck.alpha <= 0.01 { + return false + } + + if viewToCheck === ancestor { + return true + } + + currentView = viewToCheck.superview + } + + return false + } + + private func highlightControllerNavigationView(_ view: UIView) { + guard ControllerNavigator.controllerNavigationHighlightedView !== view else { + scrollControllerNavigationViewToVisible(view) + return + } + + var navigations = self.getNavigationElements() + let shouldShowReadTip = (view as? UIStackView).map { $0.hasInfoTag || $0.isGameProfileSetting } ?? false + if !shouldShowReadTip { + navigations.removeAll { $0.action == "readTip" } + } + GamepadNavigationIllustrationHud.updateNavigationElements(navigations) + + clearControllerNavigationHighlight() + ControllerNavigator.controllerNavigationHighlightedView = view + + let overlayView = UIView() + overlayView.translatesAutoresizingMaskIntoConstraints = false + overlayView.isUserInteractionEnabled = false + overlayView.backgroundColor = ThemeManager.appPrimaryColorWithAlpha + overlayView.layer.cornerRadius = 8 + overlayView.layer.cornerCurve = .continuous + overlayView.layer.masksToBounds = true + overlayView.alpha = 0 + view.addSubview(overlayView) + NSLayoutConstraint.activate([ + overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + overlayView.topAnchor.constraint(equalTo: view.topAnchor), + overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + view.bringSubviewToFront(overlayView) + controllerNavigationHighlightOverlayView = overlayView + + UIView.animate(withDuration: 0.12) { + overlayView.alpha = 1 + } + + scrollControllerNavigationViewToVisible(view) + } + + private func clearControllerNavigationHighlight() { + guard let overlayView = controllerNavigationHighlightOverlayView else { + ControllerNavigator.controllerNavigationHighlightedView = nil + return + } + + UIView.animate(withDuration: 0.08, animations: { + overlayView.alpha = 0 + }, completion: { _ in + overlayView.removeFromSuperview() + }) + + ControllerNavigator.controllerNavigationHighlightedView = nil + controllerNavigationHighlightOverlayView = nil + } + + fileprivate func clearControllerNavigationHighlightForControllerNavigator() { + clearControllerNavigationHighlight() + } + + private func scrollControllerNavigationViewToVisible(_ view: UIView) { + guard let scrollView = scrollView, + let parentStack = parentStack else { return } + + scrollView.layoutIfNeeded() + parentStack.layoutIfNeeded() + + let targetRectInParentStack = view.convert(view.bounds, to: parentStack) + let targetRectInContent = targetRectInParentStack.offsetBy( + dx: parentStack.frame.minX, + dy: parentStack.frame.minY + ) + + let edgePadding: CGFloat = 72 + let adjustedInset = scrollView.adjustedContentInset + let visibleMinY = scrollView.contentOffset.y + adjustedInset.top + let visibleMaxY = scrollView.contentOffset.y + scrollView.bounds.height - adjustedInset.bottom + + var targetOffsetY = scrollView.contentOffset.y + if targetRectInContent.minY < visibleMinY + edgePadding { + targetOffsetY = targetRectInContent.minY - edgePadding - adjustedInset.top + } else if targetRectInContent.maxY > visibleMaxY - edgePadding { + targetOffsetY = targetRectInContent.maxY + edgePadding + adjustedInset.bottom - scrollView.bounds.height + } + + let minOffsetY = -adjustedInset.top + let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + adjustedInset.bottom) + targetOffsetY = min(max(targetOffsetY, minOffsetY), maxOffsetY) + + guard abs(targetOffsetY - scrollView.contentOffset.y) > 0.5 else { return } + UIView.animate(withDuration: 0.1) { + scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: targetOffsetY), animated: false) } } } + +extension UICollectionViewController { + var controllerNavigationSelectedIndexPath: IndexPath? { + get { + objc_getAssociatedObject(self, &controllerNavigationSelectedIndexPathKey) as? IndexPath + } + set { + objc_setAssociatedObject(self, &controllerNavigationSelectedIndexPathKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private var controllerNavigationPersistToken: Int { + get { + objc_getAssociatedObject(self, &controllerNavigationPersistTokenKey) as? Int ?? 0 + } + set { + objc_setAssociatedObject(self, &controllerNavigationPersistTokenKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + func applyControllerNavigationHighlight(to cell: UICollectionViewCell, highlighted: Bool) { + clearControllerNavigationHighlightBorder(in: cell) + guard highlighted else { return } + let highlightedView = controllerNavigationHighlightTargetView(for: cell) + highlightedView.layer.borderWidth = (self is HostCollectionViewController) ? 3 : 5 + let isDarkTheme = ThemeManager.userInterfaceStyle() == .dark + highlightedView.layer.borderColor = ThemeManager.appPrimaryColor.withAlphaComponent(isDarkTheme ? 0.85 : 0.93).cgColor + } + + private func clearControllerNavigationHighlightBorder(in cell: UICollectionViewCell) { + for view in [cell, cell.contentView] + cell.contentView.subviews + cell.subviews { + view.layer.borderWidth = 0 + view.layer.borderColor = nil + } + } + + private func controllerNavigationHighlightTargetView(for cell: UICollectionViewCell) -> UIView { + if let contentView = controllerNavigationContentView(in: cell) { + return contentView + } + + return cell.contentView + } + + private func controllerNavigationContentView(in cell: UICollectionViewCell) -> UIView? { + let contentSubviews = cell.contentView.subviews.filter { + !$0.isHidden && $0.alpha > 0.01 && !$0.bounds.isEmpty + } + if let contentSubview = contentSubviews.first { + return contentSubview + } + + let cellSubviews = cell.subviews.filter { + $0 !== cell.contentView && !$0.isHidden && $0.alpha > 0.01 && !$0.bounds.isEmpty + } + return cellSubviews.first + } +} + +@available(iOS 13.0, *) +extension UICollectionViewController: ControllerUINavigationDelegate { + func persistControllerNavigationHighlight() { + if let hostCollectionVC = self as? HostCollectionViewController { + persistHostControllerNavigationHighlight(in: hostCollectionVC) + return + } + + if self is MainFrameViewController { + persistAppControllerNavigationHighlight() + } + } + + func restoreControllerNavigationHighlight() { + guard ControllerNavigator.enabled, ControllerUtil.primaryGCController != nil else {return} + + PublicUtils.runOnMain { [weak self] in + guard let self else { return } + + if let hostCollectionVC = self as? HostCollectionViewController { + self.restoreHostControllerNavigationHighlight(in: hostCollectionVC) + return + } + + if let mainFrameVC = self as? MainFrameViewController { + self.restoreAppControllerNavigationHighlight(in: mainFrameVC) + } + } + } + + func restoreControllerNavigationHighlightAfterSettingsModeSwitch() { + } + + private func currentControllerNavigationCell() -> UICollectionViewCell? { + guard let selectedIndexPath = controllerNavigationSelectedIndexPath, + selectedIndexPath.section < collectionView.numberOfSections, + selectedIndexPath.item >= 0, + selectedIndexPath.item < collectionView.numberOfItems(inSection: selectedIndexPath.section) else { + return nil + } + + return collectionView.cellForItem(at: selectedIndexPath) + } + + private func currentControllerNavigationContentView() -> UIView? { + guard let cell = currentControllerNavigationCell() else { return nil } + return controllerNavigationContentView(in: cell) + } + + func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) { + PublicUtils.runOnMain { [weak self] in + guard let self else { return } + + if pressed { + if self is HostCollectionViewController { + guard let hostCardView = self.currentControllerNavigationContentView() as? HostCardView else { return } + + switch navigation.action { + case "launchPairWake": + hostCardView.launchButtonTapped() + hostCardView.pairButtonTapped() + hostCardView.wakeupButtonTapped() + case "applications": + hostCardView.appButtonTapped() + default: + break + } + } + if let mainFrameVC = self as? MainFrameViewController { + let appView = self.currentControllerNavigationContentView() as? UIAppView + switch navigation.action { + case "launch": + mainFrameVC.forceLaunchApp(appView?.app) + case "quitApp": + PublicUtils.runOnMain { + mainFrameVC.quitApp(appView?.app) + } + default: + break + } + } + } + } + } + + func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) { + } + + func getNavigationElements() -> [ControllerNavigationElement] { + var elements: [ControllerNavigationElement] = [] + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButton, action: "radialMenu")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .rightStick : .leftStick, action: "focusNavigation")) + if self is HostCollectionViewController {elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: "applications"))} + if self is HostCollectionViewController {elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .x, action: "launchPairWake"))} + if self is MainFrameViewController {elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: "launch"))} + if self is MainFrameViewController {elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .x, action: "quitApp"))} + return elements + } + + func navigateByController(forward: Bool) { + guard hasNoPresentedVC else { return } + performControllerNavigationSelectionMove(horizontalOffset: forward ? 1 : -1) + scheduleControllerNavigationHighlightPersistenceWhenStickSettles() + } + + func navigateByController(downward: Bool) { + guard hasNoPresentedVC else { return } + performControllerNavigationSelectionMove(verticalOffset: downward ? 1 : -1) + scheduleControllerNavigationHighlightPersistenceWhenStickSettles() + } + + func uiWidgetActionForControllerNavigator(forward: Bool) { + } + + private func scheduleControllerNavigationHighlightPersistenceWhenStickSettles() { + controllerNavigationPersistToken += 1 + let token = controllerNavigationPersistToken + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in + self?.persistControllerNavigationHighlightIfStickSettled(token: token) + } + } + + private func persistControllerNavigationHighlightIfStickSettled(token: Int) { + guard controllerNavigationPersistToken == token else { return } + + if ControllerNavigator.navigationTimer?.isRunning() == true { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in + self?.persistControllerNavigationHighlightIfStickSettled(token: token) + } + return + } + + persistControllerNavigationHighlight() + } + + private func persistHostControllerNavigationHighlight(in hostCollectionVC: HostCollectionViewController) { + guard let selectedIndexPath = controllerNavigationSelectedIndexPath, + selectedIndexPath.section == 0, + selectedIndexPath.item >= 0, + selectedIndexPath.item < hostCollectionVC.items.count, + let host = hostCollectionVC.items[selectedIndexPath.item] as? TemporaryHost else { + return + } + + let uuid = host.uuid + guard !uuid.isEmpty else { return } + UserDefaults.standard.set(uuid, forKey: hostControllerNavigationHighlightedUUIDKey) + } + + private func restoreHostControllerNavigationHighlight(in hostCollectionVC: HostCollectionViewController) { + guard let uuid = UserDefaults.standard.string(forKey: hostControllerNavigationHighlightedUUIDKey), + !uuid.isEmpty else { + restoreFirstControllerNavigationItemIfNeeded() + return + } + + for index in 0.. 0, + collectionView.numberOfItems(inSection: 0) > 0 else { + clearCollectionControllerNavigationHighlight() + return + } + + highlightControllerNavigationItem(at: IndexPath(item: 0, section: 0)) + } + + private func performControllerNavigationSelectionMove(horizontalOffset: Int) { + PublicUtils.runOnMain { [weak self] in + self?.moveControllerNavigationSelection(horizontalOffset: horizontalOffset) + } + } + + private func performControllerNavigationSelectionMove(verticalOffset: Int) { + PublicUtils.runOnMain { [weak self] in + self?.moveControllerNavigationSelection(verticalOffset: verticalOffset) + } + } + + private func moveControllerNavigationSelection(horizontalOffset: Int) { + let itemCount = collectionView.numberOfItems(inSection: 0) + guard itemCount > 0 else { + clearCollectionControllerNavigationHighlight() + return + } + + guard let currentIndexPath = currentControllerNavigationIndexPath() else { + highlightControllerNavigationItem(at: IndexPath(item: horizontalOffset >= 0 ? 0 : itemCount - 1, section: 0)) + return + } + + let nextItem = (currentIndexPath.item + horizontalOffset + itemCount) % itemCount + highlightControllerNavigationItem(at: IndexPath(item: nextItem, section: currentIndexPath.section)) + } + + private func moveControllerNavigationSelection(verticalOffset: Int) { + let itemCount = collectionView.numberOfItems(inSection: 0) + guard itemCount > 0 else { + clearCollectionControllerNavigationHighlight() + return + } + + guard let currentIndexPath = currentControllerNavigationIndexPath(), + let currentAttributes = layoutAttributesForItem(at: currentIndexPath) else { + highlightControllerNavigationItem(at: IndexPath(item: verticalOffset >= 0 ? 0 : itemCount - 1, section: 0)) + return + } + + let currentCenter = currentAttributes.center + let candidates = (0.. (indexPath: IndexPath, attributes: UICollectionViewLayoutAttributes)? in + let indexPath = IndexPath(item: item, section: 0) + guard indexPath != currentIndexPath, + let attributes = layoutAttributesForItem(at: indexPath) else { + return nil + } + + let isTargetDirection = verticalOffset > 0 + ? attributes.center.y > currentCenter.y + 1 + : attributes.center.y < currentCenter.y - 1 + guard isTargetDirection else { return nil } + + return (indexPath, attributes) + } + + let nextIndexPath = candidates.min { lhs, rhs in + let lhsVerticalDistance = abs(lhs.attributes.center.y - currentCenter.y) + let rhsVerticalDistance = abs(rhs.attributes.center.y - currentCenter.y) + if abs(lhsVerticalDistance - rhsVerticalDistance) > 0.5 { + return lhsVerticalDistance < rhsVerticalDistance + } + + let lhsHorizontalDistance = abs(lhs.attributes.center.x - currentCenter.x) + let rhsHorizontalDistance = abs(rhs.attributes.center.x - currentCenter.x) + if abs(lhsHorizontalDistance - rhsHorizontalDistance) > 0.5 { + return lhsHorizontalDistance < rhsHorizontalDistance + } + + return verticalOffset > 0 + ? lhs.indexPath.item < rhs.indexPath.item + : lhs.indexPath.item > rhs.indexPath.item + }?.indexPath + + if let nextIndexPath { + highlightControllerNavigationItem(at: nextIndexPath) + } + } + + private func currentControllerNavigationIndexPath() -> IndexPath? { + if let selectedIndexPath = controllerNavigationSelectedIndexPath, + selectedIndexPath.section < collectionView.numberOfSections, + selectedIndexPath.item < collectionView.numberOfItems(inSection: selectedIndexPath.section) { + return selectedIndexPath + } + + if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView, + let highlightedCell = collectionViewCell(containing: highlightedView), + let indexPath = collectionView.indexPath(for: highlightedCell) { + return indexPath + } + + return nil + } + + private func highlightControllerNavigationItem(at indexPath: IndexPath) { + PublicUtils.runOnMain { [weak self] in + guard let self else { return } + + guard indexPath.section < self.collectionView.numberOfSections, + indexPath.item >= 0, + indexPath.item < self.collectionView.numberOfItems(inSection: indexPath.section) else { + self.clearCollectionControllerNavigationHighlight() + return + } + + self.collectionView.layoutIfNeeded() + + let previousIndexPath = self.controllerNavigationSelectedIndexPath + self.controllerNavigationSelectedIndexPath = indexPath + + if let previousIndexPath, + previousIndexPath != indexPath, + let previousCell = self.collectionView.cellForItem(at: previousIndexPath) { + self.applyControllerNavigationHighlight(to: previousCell, highlighted: false) + } + + self.collectionView.scrollToItem(at: indexPath, at: [.centeredHorizontally, .centeredVertically], animated: true) + + if let cell = self.collectionView.cellForItem(at: indexPath) { + self.applyControllerNavigationHighlight(to: cell, highlighted: true) + ControllerNavigator.controllerNavigationHighlightedView = self.controllerNavigationHighlightTargetView(for: cell) + } + + DispatchQueue.main.async { [weak self] in + guard let self, + self.controllerNavigationSelectedIndexPath == indexPath, + let cell = self.collectionView.cellForItem(at: indexPath) else { + return + } + + self.applyControllerNavigationHighlight(to: cell, highlighted: true) + ControllerNavigator.controllerNavigationHighlightedView = self.controllerNavigationHighlightTargetView(for: cell) + } + } + } + + private func clearCollectionControllerNavigationHighlight() { + PublicUtils.runOnMain { [weak self] in + guard let self else { return } + + if let selectedIndexPath = self.controllerNavigationSelectedIndexPath, + let cell = self.collectionView.cellForItem(at: selectedIndexPath) { + self.applyControllerNavigationHighlight(to: cell, highlighted: false) + } + + self.controllerNavigationSelectedIndexPath = nil + ControllerNavigator.controllerNavigationHighlightedView = nil + } + } + + fileprivate func clearCollectionControllerNavigationHighlightForControllerNavigator() { + clearCollectionControllerNavigationHighlight() + } + + private func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) + } + + private func collectionViewCell(containing view: UIView) -> UICollectionViewCell? { + var currentView: UIView? = view + while let viewToCheck = currentView { + if let cell = viewToCheck as? UICollectionViewCell { + return cell + } + + currentView = viewToCheck.superview + } + + return nil + } +} + +extension MainFrameViewController { + func forceLaunchApp(_ app: TemporaryApp?){ + if let currentRunningApp = self.findRunningApp(app?.host) { + if currentRunningApp !== app { + self.quitRunningAppAndStart(app) + } + else { + self.launch(currentRunningApp) + } + } + else { + self.launch(app) + } + } +} + +private var alertControllerNavigationHookInstalledKey: UInt8 = 0 + +@available(iOS 13.0, *) +extension UIAlertController: ControllerUINavigationDelegate { + static func installControllerNavigationDelegateHook() { + guard objc_getAssociatedObject(self, &alertControllerNavigationHookInstalledKey) == nil else { return } + objc_setAssociatedObject(self, &alertControllerNavigationHookInstalledKey, true, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + swizzleAlertLifecycleMethod( + originalSelector: #selector(UIViewController.viewDidAppear(_:)), + swizzledSelector: #selector(UIAlertController.vl_controllerNavigationViewDidAppear(_:)) + ) + swizzleAlertLifecycleMethod( + originalSelector: #selector(UIViewController.viewDidDisappear(_:)), + swizzledSelector: #selector(UIAlertController.vl_controllerNavigationViewDidDisappear(_:)) + ) + } + + private static func swizzleAlertLifecycleMethod(originalSelector: Selector, swizzledSelector: Selector) { + guard let originalMethod = class_getInstanceMethod(UIAlertController.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(UIAlertController.self, swizzledSelector) else { + return + } + + let didAddMethod = class_addMethod( + UIAlertController.self, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod) + ) + + if didAddMethod { + class_replaceMethod( + UIAlertController.self, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod) + ) + } else { + method_exchangeImplementations(originalMethod, swizzledMethod) + } + } + + @objc private func vl_controllerNavigationViewDidAppear(_ animated: Bool) { + vl_controllerNavigationViewDidAppear(animated) + ControllerNavigator.setUINavigationDelegate(self) + } + + @objc private func vl_controllerNavigationViewDidDisappear(_ animated: Bool) { + vl_controllerNavigationViewDidDisappear(animated) + ControllerNavigator.restorePreviousUINavigationDelegate(ifCurrentDelegateIs: self) + } + + func getNavigationElements() -> [ControllerNavigationElement] { + let enabledActions = actions.filter { $0.isEnabled } + guard let firstAction = enabledActions.first else { return [] } + + guard enabledActions.count > 1, let lastAction = enabledActions.last else { + guard let title = firstAction.title, !title.isEmpty else { return [] } + return [ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .x, action: title)] + } + + return [ + ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadLeft : .x, action: firstAction.title ?? ""), + ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .b, action: lastAction.title ?? "") + ].filter { !$0.action.isEmpty } + } + + func navigateByController(forward: Bool) {} + func navigateByController(downward: Bool) {} + func persistControllerNavigationHighlight() {} + func restoreControllerNavigationHighlight() {} + func restoreControllerNavigationHighlightAfterSettingsModeSwitch() {} + + func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) {} + + func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) { + guard pressed, + let action = actions.first(where: { $0.title == navigation.action && $0.isEnabled }) else { + return + } + + performControllerNavigationAction(action) + } + + private func performControllerNavigationAction(_ action: UIAlertAction) { + let selector = NSSelectorFromString("_invokeHandlersForAction:") + if responds(to: selector) { + self.perform(selector, with: action) + dismiss(animated: true) + return + } + } +} + +@available(iOS 13.0, *) +extension StreamFrameViewController: ControllerUINavigationDelegate { + func getNavigationElements() -> [ControllerNavigationElement] { + var elements = [ControllerNavigationElement]() + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButton, action: "radialMenu")) + return elements + } + + func navigateByController(forward: Bool) {} + func navigateByController(downward: Bool) {} + func persistControllerNavigationHighlight() {} + func restoreControllerNavigationHighlight() {} + func restoreControllerNavigationHighlightAfterSettingsModeSwitch() {} + func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) {} + func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) {} +} diff --git a/VoidLink/Input/ControllerSupport.h b/VoidLink/Input/ControllerSupport.h index 2d2242c3..c5d83b6c 100644 --- a/VoidLink/Input/ControllerSupport.h +++ b/VoidLink/Input/ControllerSupport.h @@ -26,6 +26,8 @@ @property (readonly) bool shallDisableGyroHotSwitch; ++(ControllerSupport*) sharedInstance; + -(id) initWithConfig:(StreamConfiguration*)streamConfig delegate:(id)delegate; -(void) connectionEstablished; @@ -60,4 +62,7 @@ -(void)updateControllerSupport:(StreamConfiguration*)streamConfig delegate:(id)delegate; +-(void) reinitiatePrimaryController; +-(void)sendNavigationButtonPress; + @end diff --git a/VoidLink/Input/ControllerSupport.m b/VoidLink/Input/ControllerSupport.m index dbf1b11b..c89dde30 100644 --- a/VoidLink/Input/ControllerSupport.m +++ b/VoidLink/Input/ControllerSupport.m @@ -24,6 +24,7 @@ @import AudioToolbox; static const double MOUSE_SPEED_DIVISOR = 1.25; +static __weak ControllerSupport *VLSharedControllerSupport = nil; @interface ControllerSupport() @@ -50,22 +51,7 @@ static const double MOUSE_SPEED_DIVISOR = 1.25; float accumulatedScrollX; float accumulatedScrollY; - int _controllerMouseSwitch; - bool _mouseSwitchButtonPressed; - bool _mouseSwitchButtonBeingClicked; - NSTimeInterval mouseSwitchDownTimestamp; - int _controllerMouseLeftButton; - int _controllerMouseRightButton; - ControllerMouseStick _controllerMouseStick; - bool _mapControllerToMouse; - bool _controllerMouseEnabledFlag; - float stickToMouseInputX; - float stickToMouseInputY; - float stickToWheelInputX; - float stickToWheelInputY; - float _stickToMouseExpo; - float _stickToMouseVelocity; - CADisplayLink *_displayLink; + bool _controllerNavigationEnabled; float stickMaxOffset; float _leftStickMinOffset; @@ -100,6 +86,11 @@ static const double MOUSE_SPEED_DIVISOR = 1.25; __weak MotionHandler* motionHandler; } ++(ControllerSupport*) sharedInstance +{ + return VLSharedControllerSupport; +} + // UPDATE_BUTTON_FLAG(controller, flag, pressed) #define UPDATE_BUTTON_FLAG(controller, x, y) \ ((y) ? [self setButtonFlag:controller flags:x] : [self clearButtonFlag:controller flags:x]) @@ -1020,39 +1011,6 @@ double rc_expo(double x, double expo) { return x > 0 ? y : -y; } -- (void)sendStickToMouseMoveEventWithStickX:(float)stickX stickY:(float)stickY expo:(float)expo { - CGFloat mouseDeltaX = _stickToMouseVelocity*rc_expo(stickX, expo); - CGFloat mouseDeltaY = _stickToMouseVelocity*rc_expo(stickY, expo); - LiSendMouseMoveEvent(mouseDeltaX, -mouseDeltaY); -} - -- (void)stopDisplayLink { - [_displayLink invalidate]; - _displayLink = nil; -} - -- (void)displayLinkCallBack { - if(!_controllerMouseEnabledFlag){ - return; - } - NSTimeInterval delay = 0.5/_displayLink.preferredFramesPerSecond*NSEC_PER_SEC; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [self sendStickToMouseMoveEventWithStickX:self->stickToMouseInputX stickY:self->stickToMouseInputY expo:self->_stickToMouseExpo]; - LiSendHighResScrollEvent(15*self->stickToWheelInputY); - LiSendHighResHScrollEvent(15*self->stickToWheelInputX); - }); -} - -- (void)sendControllerMouseSwitchClick:(VoidController*) voidController{ - [self setButtonFlag:voidController flags:self->_controllerMouseSwitch]; - _mouseSwitchButtonBeingClicked = true; - [self updateFinished:voidController]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.03*NSEC_PER_SEC), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [self clearButtonFlag:voidController flags:self->_controllerMouseSwitch]; - self->_mouseSwitchButtonBeingClicked = false; - [self updateFinished:voidController]; - }); -} - (bool)useMotionHandler{ return self->tempSettings.emulatedControllerType.intValue!=LI_CTYPE_PS @@ -1068,6 +1026,199 @@ double rc_expo(double x, double expo) { else self->_gyroEnabledFlag = self->_gyroEnabledFlag || !self->_controllerGyroSwitchEnabled; } +- (void)sendNavigationButtonPress { + if(!ControllerUtil.primaryGCController) return; + if(@available(iOS 13.0, *)) if(ControllerNavigator.controllerMouseEnabled) return; + VoidController* voidController = [self->_voidControllers objectForKey:@(ControllerUtil.primaryGCController.playerIndex)]; + int radialMenuButton = ControllerElementNull; + if (@available(iOS 13.0, *)) radialMenuButton = ControllerNavigator.radialMenuButton; + [self setButtonFlag:voidController flags:(int)radialMenuButton]; + [self updateFinished:voidController]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.03*NSEC_PER_SEC), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [self clearButtonFlag:voidController flags:radialMenuButton]; + [self updateFinished:voidController]; + }); +} + + +- (void) listenToGCController:(GCController*) controller { + /* + if(self->_swapABXYButtons){ + switch (self->_controllerMouseLeftButton) { + case ControllerElementA: + self->_controllerMouseLeftButton = ControllerElementB; + break; + case ControllerElementB: + self->_controllerMouseLeftButton = ControllerElementA; + break; + case ControllerElementX: + self->_controllerMouseLeftButton = ControllerElementY; + break; + case ControllerElementY: + self->_controllerMouseLeftButton = ControllerElementX; + break; + default: + break; + } + switch (self->_controllerMouseRightButton) { + case ControllerElementA: + self->_controllerMouseRightButton = ControllerElementB; + break; + case ControllerElementB: + self->_controllerMouseRightButton = ControllerElementA; + break; + case ControllerElementX: + self->_controllerMouseRightButton = ControllerElementY; + break; + case ControllerElementY: + self->_controllerMouseRightButton = ControllerElementX; + break; + default: + break; + } + } + */ + + __block ControllerElement radialMenuButton = ControllerElementNull; + __block bool controllerNavigatorEnabled = false; + + [ControllerUtil listenWithController:controller swapABXY:self->_swapABXYButtons handler:^(NSDictionary * elementDict, GCExtendedGamepad * gamepad, GCControllerElement * element) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{ + VoidController* voidController = [self->_voidControllers objectForKey:[NSNumber numberWithInteger:gamepad.controller.playerIndex]]; + short leftStickX, leftStickY; + short rightStickX, rightStickY; + unsigned char leftTrigger, rightTrigger; + + if (@available(iOS 13.0, *)){ + radialMenuButton = ControllerNavigator.radialMenuButton; + controllerNavigatorEnabled = ControllerNavigator.enabled; + } + + for(NSNumber* elementFlagId in elementDict){ + GCControllerElement * element = (GCControllerElement *)elementDict[elementFlagId]; + if ([element isKindOfClass:[GCControllerButtonInput class]]) { + GCControllerButtonInput* button = (GCControllerButtonInput *)element; + if(controllerNavigatorEnabled && voidController.playerIndex==0 && elementFlagId.intValue == radialMenuButton){ + if(!button.pressed){ + // [self sendNavigationButtonPress:voidController]; + } + } + + // controller switch buttons + if(true){ + if(button.pressed){ + if (elementFlagId.intValue == self->_controllerGyroSwitchToggle + && !self->_controllerGyroSwitchTogglePressed) { + self->_controllerGyroSwitchTogglePressed = true; + + self->_gyroEnabledFlag = !self->_gyroEnabledFlag; + [self switchMotionControlOnOffByControllerButton]; + } + if (elementFlagId.intValue == self->_controllerGyroSwitchHold + && !self->_controllerGyroSwitchHoldPressed) { + self->_controllerGyroSwitchHoldPressed = true; + + self->_gyroEnabledFlag = !self->_reverseHoldButton; + [self switchMotionControlOnOffByControllerButton]; + } + } + else{ + if (elementFlagId.intValue == self->_controllerGyroSwitchToggle + && self->_controllerGyroSwitchTogglePressed) { + self->_controllerGyroSwitchTogglePressed = false; + } + if (elementFlagId.intValue == self->_controllerGyroSwitchHold + && self->_controllerGyroSwitchHoldPressed) { + self->_controllerGyroSwitchHoldPressed = false; + + self->_gyroEnabledFlag = self->_reverseHoldButton; + [self switchMotionControlOnOffByControllerButton]; + } + } + } + + if(controller != ControllerUtil.primaryGCController || elementFlagId.intValue!=radialMenuButton || !controllerNavigatorEnabled) UPDATE_BUTTON_FLAG(voidController, elementFlagId.intValue, button.pressed); + } + } + + CGFloat leftStickXRaw = gamepad.leftThumbstick.xAxis.value * self->stickMaxOffset; + CGFloat leftStickYRaw = gamepad.leftThumbstick.yAxis.value * self->stickMaxOffset; + + CGFloat rightStickXRaw = gamepad.rightThumbstick.xAxis.value * self->stickMaxOffset; + CGFloat rightStickYRaw = gamepad.rightThumbstick.yAxis.value * self->stickMaxOffset; + + CGVector leftStickOffset = [ControllerUtil compensatedWithOffsetVector:CGVectorMake(leftStickXRaw, leftStickYRaw) minOffset:self->_leftStickMinOffset circulate:false]; + + CGVector rightStickOffset = [ControllerUtil compensatedWithOffsetVector:CGVectorMake(rightStickXRaw, rightStickYRaw) minOffset:self->_rightStickMinOffset circulate:false]; + + leftStickX = leftStickOffset.dx; + leftStickY = leftStickOffset.dy; + + rightStickX = rightStickOffset.dx; + rightStickY = rightStickOffset.dy; + + /* + if(self->oscProfile.mapGyroTo!=mapGyroToControllerStick + ||!self->oscProfile.rollToLeftStick) [self updateLeftStick:voidController x:leftStickX y:leftStickY]; + */ + + if([self useMotionHandler] + && self->oscProfile.mapGyroTo==mapGyroToControllerStick + && self->oscProfile.yawPitchToRightStick + && self->_gyroEnabledFlag + ) { + [self->motionHandler mixPhysicalRightStickAndGyroInputWithX:rightStickX y:rightStickY]; + } + else [self updateRightStick: voidController.playerIndex==0?self->_oscController:voidController x:rightStickX y:rightStickY]; + + if([self useMotionHandler] + && self->oscProfile.mapGyroTo==mapGyroToControllerStick + && self->oscProfile.rollToLeftStick + && self->_gyroEnabledFlag + ) [self->motionHandler mixPhysicalLeftStickAndGyroInputWithX:leftStickX y:leftStickY]; + else [self updateLeftStick: voidController.playerIndex==0?self->_oscController:voidController x:leftStickX y:leftStickY]; + + leftTrigger = gamepad.leftTrigger.value * 0xFF; + rightTrigger = gamepad.rightTrigger.value * 0xFF; + [self updateTriggers:voidController left:leftTrigger right:rightTrigger]; + + [self updateFinished:voidController]; + + if (@available(iOS 14.0, *)) { + if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne]) { + [self handleControllerTouchpad:voidController + touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne] + index:0]; + } + if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo]) { + [self handleControllerTouchpad:voidController + touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo] + index:1]; + } + } + }); + }]; +} + +-(void) reinitiatePrimaryController { + if(@available(iOS 13.0, *)){ + if(ControllerNavigator.controllerMouseEnabled){ + [ControllerNavigator startControllerMouse]; + return; + } + if(ControllerNavigator.radialMenuView) return; + } + if(![StreamFrameViewController sharedInstance]) return; + MainFrameViewController* mainFrameVC =(MainFrameViewController* )ControllerUtil.delegate; + if(mainFrameVC.settingsExpandedInStreamView) return; + [ControllerUtil stopListeningPrimaryControllerWithStopListenToRadialMenuButton:false]; + [self listenToGCController: ControllerUtil.primaryGCController]; + if (@available(iOS 13.0, *)){ + NSLog(@"ControllerUtil setUINavigationDelegate %f",CACurrentMediaTime()); + [ControllerNavigator setStreamFrameVCAsUINavigationDelegate]; + } +} + -(void) registerControllerCallbacks:(GCController*) controller { if (controller != NULL) { @@ -1106,299 +1257,19 @@ double rc_expo(double x, double expo) { if (controller.extendedGamepad != NULL) { // Disable system gestures on the gamepad to avoid interfering // with in-game controller actions + if (@available(iOS 14.0, tvOS 14.0, *)) { for (GCControllerElement* element in controller.physicalInputProfile.allElements) { element.preferredSystemGestureState = GCSystemGestureStateDisabled; } } - if(self->_swapABXYButtons){ - switch (self->_controllerMouseLeftButton) { - case ControllerButtonA: - self->_controllerMouseLeftButton = ControllerButtonB; - break; - case ControllerButtonB: - self->_controllerMouseLeftButton = ControllerButtonA; - break; - case ControllerButtonX: - self->_controllerMouseLeftButton = ControllerButtonY; - break; - case ControllerButtonY: - self->_controllerMouseLeftButton = ControllerButtonX; - break; - default: - break; - } - switch (self->_controllerMouseRightButton) { - case ControllerButtonA: - self->_controllerMouseRightButton = ControllerButtonB; - break; - case ControllerButtonB: - self->_controllerMouseRightButton = ControllerButtonA; - break; - case ControllerButtonX: - self->_controllerMouseRightButton = ControllerButtonY; - break; - case ControllerButtonY: - self->_controllerMouseRightButton = ControllerButtonX; - break; - default: - break; - } - } - - - [ControllerUtil listenWithController:controller swapABXY:self->_swapABXYButtons handler:^(NSDictionary * buttonDict, GCExtendedGamepad * gamepad, GCControllerElement * element) { - dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{ - VoidController* voidController = [self->_voidControllers objectForKey:[NSNumber numberWithInteger:gamepad.controller.playerIndex]]; - short leftStickX, leftStickY; - short rightStickX, rightStickY; - unsigned char leftTrigger, rightTrigger; - - for(NSNumber* buttonFlagId in buttonDict){ - GCControllerButtonInput * button = (GCControllerButtonInput *)buttonDict[buttonFlagId]; - if(self->_mapControllerToMouse && voidController.playerIndex==0){ - if(button.pressed){ - if(buttonFlagId.intValue == self->_controllerMouseSwitch){ - self->_mouseSwitchButtonPressed = true; - if(self->mouseSwitchDownTimestamp == 0) self->mouseSwitchDownTimestamp = CACurrentMediaTime(); - } - } - else{ - if(buttonFlagId.intValue == self->_controllerMouseSwitch && self->_mouseSwitchButtonPressed){ - if(CACurrentMediaTime()-self->mouseSwitchDownTimestamp>1){ - self->_controllerMouseEnabledFlag = !self->_controllerMouseEnabledFlag; - [self updateLeftStick:voidController x:0 y:0]; - [self updateRightStick:voidController x:0 y:0]; - } - else [self sendControllerMouseSwitchClick:voidController]; - self->_mouseSwitchButtonPressed = false; - self->mouseSwitchDownTimestamp = 0; - } - } - } - else self->_controllerMouseEnabledFlag = false; - - // controller switch buttons - if(true){ - if(button.pressed){ - if (buttonFlagId.intValue == self->_controllerGyroSwitchToggle - && !self->_controllerGyroSwitchTogglePressed) { - self->_controllerGyroSwitchTogglePressed = true; - - self->_gyroEnabledFlag = !self->_gyroEnabledFlag; - [self switchMotionControlOnOffByControllerButton]; - } - if (buttonFlagId.intValue == self->_controllerGyroSwitchHold - && !self->_controllerGyroSwitchHoldPressed) { - self->_controllerGyroSwitchHoldPressed = true; - - self->_gyroEnabledFlag = !self->_reverseHoldButton; - [self switchMotionControlOnOffByControllerButton]; - } - } - else{ - if (buttonFlagId.intValue == self->_controllerGyroSwitchToggle - && self->_controllerGyroSwitchTogglePressed) { - self->_controllerGyroSwitchTogglePressed = false; - } - if (buttonFlagId.intValue == self->_controllerGyroSwitchHold - && self->_controllerGyroSwitchHoldPressed) { - self->_controllerGyroSwitchHoldPressed = false; - - self->_gyroEnabledFlag = self->_reverseHoldButton; - [self switchMotionControlOnOffByControllerButton]; - } - } - } - - if(self->_controllerMouseEnabledFlag){ - if(buttonFlagId.intValue == self->_controllerMouseLeftButton || buttonFlagId.intValue == self->_controllerMouseRightButton){ - if(buttonFlagId.intValue == self->_controllerMouseLeftButton) LiSendMouseButtonEvent(button.pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_LEFT); - if(buttonFlagId.intValue == self->_controllerMouseRightButton) LiSendMouseButtonEvent(button.pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_RIGHT); - UPDATE_BUTTON_FLAG(voidController, buttonFlagId.intValue, NO); - } - else if(buttonFlagId.intValue!=self->_controllerMouseSwitch || !self->_mapControllerToMouse) UPDATE_BUTTON_FLAG(voidController, buttonFlagId.intValue, button.pressed); - } - else if(buttonFlagId.intValue!=self->_controllerMouseSwitch || !self->_mapControllerToMouse) UPDATE_BUTTON_FLAG(voidController, buttonFlagId.intValue, button.pressed); - } - - CGFloat leftStickXRaw = gamepad.leftThumbstick.xAxis.value * self->stickMaxOffset; - CGFloat leftStickYRaw = gamepad.leftThumbstick.yAxis.value * self->stickMaxOffset; - - CGFloat rightStickXRaw = gamepad.rightThumbstick.xAxis.value * self->stickMaxOffset; - CGFloat rightStickYRaw = gamepad.rightThumbstick.yAxis.value * self->stickMaxOffset; - - CGVector leftStickOffset = [ControllerUtil compensatedWithOffsetVector:CGVectorMake(leftStickXRaw, leftStickYRaw) minOffset:self->_leftStickMinOffset circulate:false]; - - CGVector rightStickOffset = [ControllerUtil compensatedWithOffsetVector:CGVectorMake(rightStickXRaw, rightStickYRaw) minOffset:self->_rightStickMinOffset circulate:false]; - - leftStickX = self->_controllerMouseEnabledFlag ? 0 : leftStickOffset.dx; - leftStickY = self->_controllerMouseEnabledFlag ? 0 : leftStickOffset.dy; - - rightStickX = self->_controllerMouseEnabledFlag ? 0 : rightStickOffset.dx; - rightStickY = self->_controllerMouseEnabledFlag ? 0 : rightStickOffset.dy; - - if(self->_controllerMouseEnabledFlag){ - self->stickToMouseInputX = self->_controllerMouseStick == LeftStickToMouse ? gamepad.leftThumbstick.xAxis.value : gamepad.rightThumbstick.xAxis.value; - self->stickToMouseInputY = self->_controllerMouseStick == LeftStickToMouse ? gamepad.leftThumbstick.yAxis.value : gamepad.rightThumbstick.yAxis.value; - - self->stickToWheelInputX = self->_controllerMouseStick == LeftStickToMouse ? gamepad.rightThumbstick.xAxis.value: gamepad.leftThumbstick.xAxis.value; - self->stickToWheelInputY = self->_controllerMouseStick == LeftStickToMouse ? gamepad.rightThumbstick.yAxis.value: gamepad.leftThumbstick.yAxis.value; - } - else{ - self->stickToMouseInputX = 0; - self->stickToMouseInputY = 0; - } - - /* - if(self->oscProfile.mapGyroTo!=mapGyroToControllerStick - ||!self->oscProfile.rollToLeftStick) [self updateLeftStick:voidController x:leftStickX y:leftStickY]; - */ - - if([self useMotionHandler] - && self->oscProfile.mapGyroTo==mapGyroToControllerStick - && self->oscProfile.yawPitchToRightStick - && self->_gyroEnabledFlag - ) [self->motionHandler mixPhysicalRightStickAndGyroInputWithX:rightStickX y:rightStickY]; - else [self updateRightStick: voidController.playerIndex==0?self->_oscController:voidController x:rightStickX y:rightStickY]; - - if([self useMotionHandler] - && self->oscProfile.mapGyroTo==mapGyroToControllerStick - && self->oscProfile.rollToLeftStick - && self->_gyroEnabledFlag - ) [self->motionHandler mixPhysicalLeftStickAndGyroInputWithX:leftStickX y:leftStickY]; - else [self updateLeftStick: voidController.playerIndex==0?self->_oscController:voidController x:leftStickX y:leftStickY]; - - leftTrigger = gamepad.leftTrigger.value * 0xFF; - rightTrigger = gamepad.rightTrigger.value * 0xFF; - [self updateTriggers:voidController left:leftTrigger right:rightTrigger]; - - [self updateFinished:voidController]; - - if (@available(iOS 14.0, *)) { - if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne]) { - [self handleControllerTouchpad:voidController - touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne] - index:0]; - } - if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo]) { - [self handleControllerTouchpad:voidController - touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo] - index:1]; - } - } + if(controller == ControllerUtil.primaryGCController){ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1*NSEC_PER_SEC), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [self reinitiatePrimaryController]; }); - }]; - - /* - controller.extendedGamepad.valueChangedHandler = - ^(GCExtendedGamepad *gamepad, GCControllerElement *element) { - VoidController* voidController = [self->_voidControllers objectForKey:[NSNumber numberWithInteger:gamepad.controller.playerIndex]]; - short leftStickX, leftStickY; - short rightStickX, rightStickY; - unsigned char leftTrigger, rightTrigger; - if (self->_swapABXYButtons) { - UPDATE_BUTTON_FLAG(voidController, B_FLAG, gamepad.buttonA.pressed); - UPDATE_BUTTON_FLAG(voidController, A_FLAG, gamepad.buttonB.pressed); - UPDATE_BUTTON_FLAG(voidController, Y_FLAG, gamepad.buttonX.pressed); - UPDATE_BUTTON_FLAG(voidController, X_FLAG, gamepad.buttonY.pressed); - } - else { - UPDATE_BUTTON_FLAG(voidController, A_FLAG, gamepad.buttonA.pressed); - UPDATE_BUTTON_FLAG(voidController, B_FLAG, gamepad.buttonB.pressed); - UPDATE_BUTTON_FLAG(voidController, X_FLAG, gamepad.buttonX.pressed); - UPDATE_BUTTON_FLAG(voidController, Y_FLAG, gamepad.buttonY.pressed); - } - - UPDATE_BUTTON_FLAG(voidController, UP_FLAG, gamepad.dpad.up.pressed); - UPDATE_BUTTON_FLAG(voidController, DOWN_FLAG, gamepad.dpad.down.pressed); - UPDATE_BUTTON_FLAG(voidController, LEFT_FLAG, gamepad.dpad.left.pressed); - UPDATE_BUTTON_FLAG(voidController, RIGHT_FLAG, gamepad.dpad.right.pressed); - - UPDATE_BUTTON_FLAG(voidController, LB_FLAG, gamepad.leftShoulder.pressed); - UPDATE_BUTTON_FLAG(voidController, RB_FLAG, gamepad.rightShoulder.pressed); - - // Yay, iOS 12.1 now supports analog stick buttons - if (@available(iOS 12.1, tvOS 12.1, *)) { - if (gamepad.leftThumbstickButton != nil) { - UPDATE_BUTTON_FLAG(voidController, LS_CLK_FLAG, gamepad.leftThumbstickButton.pressed); - } - if (gamepad.rightThumbstickButton != nil) { - UPDATE_BUTTON_FLAG(voidController, RS_CLK_FLAG, gamepad.rightThumbstickButton.pressed); - } - } - - if (@available(iOS 13.0, tvOS 13.0, *)) { - // Options button is optional (only present on Xbox One S and PS4 gamepads) - if (gamepad.buttonOptions != nil) { - UPDATE_BUTTON_FLAG(voidController, BACK_FLAG, gamepad.buttonOptions.pressed); - - // For older MFi gamepads, the menu button will already be handled by - // the controllerPausedHandler. - UPDATE_BUTTON_FLAG(voidController, PLAY_FLAG, gamepad.buttonMenu.pressed); - } - } - - if (@available(iOS 14.0, tvOS 14.0, *)) { - // Home/Guide button is optional (only present on Xbox One S and PS4 gamepads) - if (gamepad.buttonHome != nil) { - UPDATE_BUTTON_FLAG(voidController, SPECIAL_FLAG, gamepad.buttonHome.pressed); - } - - // Xbox One/Series controllers - if (gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleOne]) { - UPDATE_BUTTON_FLAG(voidController, PADDLE1_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleOne].pressed); - } - if (gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleTwo]) { - UPDATE_BUTTON_FLAG(voidController, PADDLE2_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleTwo].pressed); - } - if (gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleThree]) { - UPDATE_BUTTON_FLAG(voidController, PADDLE3_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleThree].pressed); - } - if (gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleFour]) { - UPDATE_BUTTON_FLAG(voidController, PADDLE4_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputXboxPaddleFour].pressed); - } - if (@available(iOS 15.0, tvOS 15.0, *)) { - if (gamepad.controller.physicalInputProfile.buttons[GCInputButtonShare]) { - UPDATE_BUTTON_FLAG(voidController, MISC_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputButtonShare].pressed); - } - } - - // DualShock/DualSense controllers - if (gamepad.controller.physicalInputProfile.buttons[GCInputDualShockTouchpadButton]) { - UPDATE_BUTTON_FLAG(voidController, TOUCHPAD_FLAG, gamepad.controller.physicalInputProfile.buttons[GCInputDualShockTouchpadButton].pressed); - } - if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne]) { - [self handleControllerTouchpad:voidController - touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne] - index:0]; - } - if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo]) { - [self handleControllerTouchpad:voidController - touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo] - index:1]; - } - } - - leftStickX = self->controllerToMouseStick != LeftStickToMouse ? gamepad.leftThumbstick.xAxis.value * 0x7FFE : 0; - leftStickY = self->controllerToMouseStick != LeftStickToMouse ? gamepad.leftThumbstick.yAxis.value * 0x7FFE : 0;; - - rightStickX = self->controllerToMouseStick != RightStickToMouse ? gamepad.rightThumbstick.xAxis.value * 0x7FFE : 0; - rightStickY = self->controllerToMouseStick != RightStickToMouse ? gamepad.rightThumbstick.yAxis.value * 0x7FFE : 0; - - self->stickToMouseInputX = self->controllerToMouseStick == LeftStickToMouse ? gamepad.leftThumbstick.xAxis.value : gamepad.rightThumbstick.xAxis.value; - self->stickToMouseInputY = self->controllerToMouseStick == LeftStickToMouse ? gamepad.leftThumbstick.yAxis.value : gamepad.rightThumbstick.yAxis.value; - - leftTrigger = gamepad.leftTrigger.value * 0xFF; - rightTrigger = gamepad.rightTrigger.value * 0xFF; - - [self updateLeftStick:voidController x:leftStickX y:leftStickY]; - [self updateRightStick:voidController x:rightStickX y:rightStickY]; - [self updateTriggers:voidController left:leftTrigger right:rightTrigger]; - [self updateFinished:voidController]; - }; - */ + } + else [self listenToGCController:controller]; } } else { Log(LOG_W, @"Tried to register controller callbacks on NULL controller"); @@ -1749,29 +1620,9 @@ double rc_expo(double x, double expo) { _oscEnabled = _oscEnabled || (OnScreenControlsLevel)[tempSettings.onscreenControls integerValue] != OnScreenControlsLevelOff || streamConfig.gyroMode != GyroModeOff; _gyroSensitivity = tempSettings.gyroSensitivity.floatValue; - _mapControllerToMouse = tempSettings.mapControllerToMouse; - _controllerMouseSwitch = tempSettings.controllerMouseSwitch.intValue; - mouseSwitchDownTimestamp = 0; - _mouseSwitchButtonPressed = false; - _mouseSwitchButtonBeingClicked = false; - _controllerMouseStick = tempSettings.controllerMouseStick.intValue; - _controllerMouseLeftButton = tempSettings.controllerMouseLeftButton.intValue; - _controllerMouseRightButton = tempSettings.controllerMouseRightButton.intValue; - _stickToMouseExpo = tempSettings.controllerMouseExpo.floatValue; - _stickToMouseVelocity = tempSettings.controllerMousePointerVelocity.floatValue*60/tempSettings.framerate.intValue; - stickToMouseInputX = 0; - stickToMouseInputY = 0; - stickToWheelInputY = 0; - [self stopDisplayLink]; - if(_mapControllerToMouse){ - _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkCallBack)]; - if (@available(iOS 15.0, tvOS 15.0, *)) { - [_displayLink setPreferredFrameRateRange:CAFrameRateRangeMake(tempSettings.framerate.intValue,tempSettings.framerate.intValue, tempSettings.framerate.intValue)]; - } - else { - _displayLink.preferredFramesPerSecond = tempSettings.framerate.intValue; - } - [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; + if (@available(iOS 13.0, *)) { + _controllerNavigationEnabled = ControllerNavigator.enabled; + [ControllerNavigator configureControllerMouseWith:tempSettings]; } _controllerGyroSwitchEnabled = oscProfile.controllerGyroSwitchMode != ControllerGyroSwitchDisabled; @@ -1787,9 +1638,11 @@ double rc_expo(double x, double expo) { _leftStickMinOffset = oscProfile.physicalLeftStickMinOffset; _rightStickMinOffset = oscProfile.physicalRightStickMinOffset; - if(oscProfile.controllerGyroSwitchMode == ControllerGyroSwitchDisabled && ![self useMotionHandler]) _gyroEnabledFlag = true; - - if(![self useMotionHandler]) [self->motionHandler stopMotionUpdateWithInterruptNoneGyroInput:false]; + // if(oscProfile.controllerGyroSwitchMode == ControllerGyroSwitchDisabled && ![self useMotionHandler]) _gyroEnabledFlag = false; + _gyroEnabledFlag = false; + + // if(![self useMotionHandler]) [self->motionHandler stopMotionUpdateWithInterruptNoneGyroInput:false]; + [self->motionHandler stopMotionUpdateWithInterruptNoneGyroInput:false]; } - (void)resetGyroInputForController:(VoidController* )voidController{ @@ -1833,6 +1686,9 @@ double rc_expo(double x, double expo) { -(id)initWithConfig:(StreamConfiguration*)streamConfig delegate:(id)delegate { self = [super init]; + if (self) { + VLSharedControllerSupport = self; + } NSLog(@"controller support init"); @@ -1985,7 +1841,7 @@ double rc_expo(double x, double expo) { [self initializeControllerHaptics:_oscController]; _gyroMode = AlwaysDevice; - _controllerMouseEnabledFlag = false; + // _controllerMouseEnabledFlag = false; _gyroEnabledFlag = false; oscProfileMan = [OSCProfilesManager sharedManager:CGRectZero]; @@ -2096,6 +1952,10 @@ double rc_expo(double x, double expo) { -(void) cleanup { + if (VLSharedControllerSupport == self) { + VLSharedControllerSupport = nil; + } + [[NSNotificationCenter defaultCenter] removeObserver:_controllerConnectObserver]; [[NSNotificationCenter defaultCenter] removeObserver:_controllerDisconnectObserver]; [[NSNotificationCenter defaultCenter] removeObserver:_mouseConnectObserver]; @@ -2137,8 +1997,13 @@ double rc_expo(double x, double expo) { [self unregisterMouseCallbacks:mouse]; } } - - [self stopDisplayLink]; +} + +-(void) dealloc +{ + if (VLSharedControllerSupport == self) { + VLSharedControllerSupport = nil; + } } @end diff --git a/VoidLink/Input/ControllerUtil.swift b/VoidLink/Input/ControllerUtil.swift index af41b89c..f34588ca 100644 --- a/VoidLink/Input/ControllerUtil.swift +++ b/VoidLink/Input/ControllerUtil.swift @@ -12,7 +12,22 @@ import Combine import CoreGraphics import Foundation -@objc enum ControllerButton: Int { +@objc enum ControllerElementType: Int { + case button + case stick + case stickAxis + case touchpad + case undefined +} + +@objc enum ControllerElementPosition: Int { + case left + case right + case middle + case undefined +} + +@objc enum ControllerElement: Int32 { // Face buttons case a = 0x1000 case b = 0x2000 @@ -34,8 +49,8 @@ import Foundation case rightStickButton = 0x0080 // Menu/Back/Special - case menu = 0x0010 // PLAY_FLAG - case back = 0x0020 // BACK_FLAG + case start = 0x0010 // PLAY_FLAG + case select = 0x0020 // BACK_FLAG case special = 0x0400 // Extended buttons (Sunshine only) @@ -49,37 +64,209 @@ import Foundation case leftTrigger = 0x400000 case rightTrigger = 0x800000 + case leftStick = 0x800010 + case leftStickX = 0x80011 + case leftStickY = 0x800012 + case rightStick = 0x800020 + case rightStickX = 0x800021 + case rightStickY = 0x800022 + case null = 0xFFFFFF + + var displayName: String { + switch self { + case .a: return "A" + case .b: return "B" + case .x: return "X" + case .y: return "Y" + + case .dpadUp: return "DPadU".localized + case .dpadDown: return "DPadD".localized + case .dpadLeft: return "DPadL".localized + case .dpadRight: return "DPadR".localized + + case .leftShoulder: return "LB" + case .rightShoulder: return "RB" + + case .leftStickButton: return "LS" + case .rightStickButton: return "RS" + + case .start: return "Start" + case .select: return "Select" + case .special: return "Home" + + case .paddle1: return "Paddle1".localized + case .paddle2: return "Paddle2".localized + case .paddle3: return "Paddle3".localized + case .paddle4: return "Paddle4".localized + case .touchpadButton: return "TouchBtn".localized + case .misc: return "Misc" + + case .leftTrigger: return "LT" + case .rightTrigger: return "RT" + + case .leftStick: return "LStick".localized + case .leftStickX: return "LStickX".localized + case .leftStickY: return "LStickY".localized + case .rightStick: return "RStick".localized + case .rightStickX: return "RStickX".localized + case .rightStickY: return "RStickY".localized + + case .null: return "Null".localized + } + } + + var symbol: String { + switch self { + case .dpadUp: + if #available(iOS 15.0, *) {return "dpad.up.filled"} + else {return ""} + case .dpadDown: + if #available(iOS 15.0, *) {return "dpad.down.filled"} + else {return ""} + case .dpadLeft: + if #available(iOS 15.0, *) {return "dpad.left.filled"} + else {return ""} + case .dpadRight: + if #available(iOS 15.0, *) {return "dpad.right.filled"} + else {return ""} + case .a: + if #available(iOS 15.0, *) {return "circle.grid.cross.down.filled"} + else {return ""} + case .b: + if #available(iOS 15.0, *) {return "circle.grid.cross.right.filled"} + else {return ""} + case .x: + if #available(iOS 15.0, *) {return "circle.grid.cross.left.filled"} + else {return ""} + case .y: + if #available(iOS 15.0, *) {return "circle.grid.cross.up.filled"} + else {return ""} + default: return "" + } + } + + var type: ControllerElementType { + switch self { + case .a: return .button + case .b: return .button + case .x: return .button + case .y: return .button + + case .dpadUp: return .button + case .dpadDown: return .button + case .dpadLeft: return .button + case .dpadRight: return .button + + case .leftShoulder: return .button + case .rightShoulder: return .button + + case .leftStickButton: return .button + case .rightStickButton: return .button + + case .start: return .button + case .select: return .button + case .special: return .button + + case .paddle1: return .button + case .paddle2: return .button + case .paddle3: return .button + case .paddle4: return .button + case .touchpadButton: return .button + case .misc: return .button + + case .leftTrigger: return .button + case .rightTrigger: return .button + + case .leftStick: return .stick + case .leftStickX: return .stickAxis + case .leftStickY: return .stickAxis + case .rightStick: return .stick + case .rightStickX: return .stickAxis + case .rightStickY: return .stickAxis + + case .null: return .undefined + } + } + + var position: ControllerElementPosition { + switch self { + case .a: return .right + case .b: return .right + case .x: return .right + case .y: return .right + + case .dpadUp: return .left + case .dpadDown: return .left + case .dpadLeft: return .left + case .dpadRight: return .left + + case .leftShoulder: return .left + case .rightShoulder: return .right + + case .leftStickButton: return .left + case .rightStickButton: return .right + + case .start: return .right + case .select: return .left + case .special: return .undefined + + case .paddle1: return .right + case .paddle2: return .right + case .paddle3: return .left + case .paddle4: return .left + case .touchpadButton: return .middle + case .misc: return .undefined + + case .leftTrigger: return .left + case .rightTrigger: return .right + + case .leftStick: return .left + case .leftStickX: return .left + case .leftStickY: return .left + case .rightStick: return .right + case .rightStickX: return .right + case .rightStickY: return .right + + case .null: return .undefined + } + } } +@objc protocol ControllerUtilDelegate: AnyObject { + func isStreaming() -> Bool + func isInAppView() -> Bool +} @objc class ControllerUtil: NSObject { + @objc static weak var delegate: ControllerUtilDelegate? static private let stickMaxOffset:CGFloat = 0x7FFE @objc static var navigationActionTriggered:Bool = false @objc static private(set) var navigationActionTriggeredPrivate:Bool = false - + @objc static func listen( controller: GCController, swapABXY: Bool, - handler: @escaping (_ buttonDict: NSDictionary, + handler: @escaping (_ elementDict: NSDictionary, _ gamepad: GCExtendedGamepad, _ element: GCControllerElement) -> Void ) { guard let gamepad = controller.extendedGamepad else { return } // 内部生成 map - var tempMap: [NSNumber: GCControllerButtonInput] = [:] + var tempMap: [NSNumber: GCControllerElement] = [:] let swiftMap = buildMapping(for: controller, swapABXY: swapABXY) for (button, input) in swiftMap { tempMap[NSNumber(value: button.rawValue)] = input } - let buttonDict = tempMap as NSDictionary + let elementDict = tempMap as NSDictionary // 单一 gamepad.valueChangedHandler gamepad.valueChangedHandler = { gamepad, element in - handler(buttonDict, gamepad, element) + handler(elementDict, gamepad, element) if #available(iOS 13.0, *) { + // print("controller.playerIndex \(controller.playerIndex)") if controller.playerIndex == .index1 { GamepadOverlayStateCenter.shared.publish(snapshot: GamepadOverlaySnapshot(gamepad: gamepad)) } @@ -87,12 +274,12 @@ import Foundation } } - // MARK: - 构建按钮映射 - private static func buildMapping(for controller: GCController, swapABXY:Bool) - -> [ControllerButton : GCControllerButtonInput] + static func buildMapping(for controller: GCController, swapABXY:Bool) + -> [ControllerElement : GCControllerElement] { - var result: [ControllerButton : GCControllerButtonInput] = [:] + var result: [ControllerElement : GCControllerElement] = [:] + ControllerUtil.swapABXY = swapABXY if let pad = controller.extendedGamepad { // Face buttons @@ -114,7 +301,7 @@ import Foundation result[.rightShoulder] = pad.rightShoulder result[.leftTrigger] = pad.leftTrigger result[.rightTrigger] = pad.rightTrigger - + // Stick buttons if #available(iOS 12.1, *) { result[.leftStickButton] = pad.leftThumbstickButton @@ -127,10 +314,19 @@ import Foundation result[.dpadLeft] = pad.dpad.left result[.dpadRight] = pad.dpad.right + // Sticks + result[.leftStick] = pad.leftThumbstick + result[.leftStickX] = pad.leftThumbstick.xAxis + result[.leftStickY] = pad.leftThumbstick.yAxis + result[.rightStick] = pad.rightThumbstick + result[.rightStickX] = pad.rightThumbstick.xAxis + result[.rightStickY] = pad.rightThumbstick.yAxis + + // Menu / Options / Share if #available(iOS 13.0, *) { - result[.menu] = pad.buttonMenu - if let options = pad.buttonOptions { result[.back] = options } + result[.start] = pad.buttonMenu + if let options = pad.buttonOptions { result[.select] = options } } if #available(iOS 14.0, tvOS 14.0, *) { @@ -163,49 +359,185 @@ import Foundation } } } + + if controller === primaryGCController {ControllerUtil.primaryControllerElementMap = result} + return result } @objc static var activeStreamingGCControllers:NSMutableSet = NSMutableSet() - @objc static func string(for button: ControllerButton) -> String { - switch button { - case .a: return "A" - case .b: return "B" - case .x: return "X" - case .y: return "Y" - - case .dpadUp: return LocalizationHelper.localizedString(forKey: "Up") - case .dpadDown: return LocalizationHelper.localizedString(forKey: "Down") - case .dpadLeft: return LocalizationHelper.localizedString(forKey: "Left") - case .dpadRight: return LocalizationHelper.localizedString(forKey: "Right") - - case .leftShoulder: return "LB" - case .rightShoulder: return "RB" - - case .leftStickButton: return "LS" - case .rightStickButton: return "RS" - - case .menu: return "Menu" - case .back: return "Back" - case .special: return "Home" - - case .paddle1: return LocalizationHelper.localizedString(forKey: "Paddle1") - case .paddle2: return LocalizationHelper.localizedString(forKey: "Paddle2") - case .paddle3: return LocalizationHelper.localizedString(forKey: "Paddle3") - case .paddle4: return LocalizationHelper.localizedString(forKey: "Paddle4") - case .touchpadButton: return LocalizationHelper.localizedString(forKey: "Touch button") - case .misc: return "Misc" - - case .leftTrigger: return "LT" - case .rightTrigger: return "RT" - - case .null: return LocalizationHelper.localizedString(forKey: "Null") - - default: return "UNKNOWN" + @objc static var swapABXY:Bool = false + + @objc static func string(for element: ControllerElement) -> String { + return element.displayName + } + + @objc static func position(for element: ControllerElement) -> ControllerElementPosition { + return element.position + } + + + // MARK: - primary controller section + + private static var connectObserver: NSObjectProtocol? + private static var disconnectObserver: NSObjectProtocol? + + @objc static weak var primaryGCController: GCController? + static var primaryControllerElementMap: [ControllerElement : GCControllerElement]? + private static var primaryControllerAxisStates: [ControllerElement : ComparisonResult] = [:] + + @objc static func installControllerObserversIfNeeded() { + guard connectObserver == nil, disconnectObserver == nil else { return } + + if GCController.controllers().first(where: { $0.extendedGamepad != nil }) != nil { + preparePrimaryController() + } + + connectObserver = NotificationCenter.default.addObserver( + forName: .GCControllerDidConnect, + object: nil, + queue: .main + ) { notification in + guard (notification.object as? GCController)?.extendedGamepad != nil else { return } + preparePrimaryController() + } + + disconnectObserver = NotificationCenter.default.addObserver( + forName: .GCControllerDidDisconnect, + object: nil, + queue: .main + ) { notification in + preparePrimaryController() + } + } + + @objc static func removeControllerObservers() { + if let connectObserver { + NotificationCenter.default.removeObserver(connectObserver) + self.connectObserver = nil + } + + if let disconnectObserver { + NotificationCenter.default.removeObserver(disconnectObserver) + self.disconnectObserver = nil } } + @objc static func disableSysGestures(_ controller: GCController?){ + if let controller = controller { + if #available(iOS 14.0, *) { + for element in controller.physicalInputProfile.allElements { + element.preferredSystemGestureState = .disabled + } + } + } + } + + private static func preparePrimaryController() { + guard let controller = GCController.controllers().first(where: { $0.extendedGamepad != nil }) else { + stopListeningPrimaryController() + ControllerUtil.primaryGCController = nil + if #available(iOS 13.0, *) { + ControllerNavigator.stop() + } + return + } + + guard GCController.controllers().count == 1 else { return } + + primaryGCController = controller + _ = buildMapping(for: controller, swapABXY: false) + disableSysGestures(controller) + + if #available(iOS 13.0, *) { + if ControllerNavigator.enabled { + ControllerNavigator.start() + GamepadNavigationIllustrationHud.updateNavigationElements(ControllerNavigator.uiNavigationDelegate?.getNavigationElements() ?? []) + ControllerNavigator.restoreUINavigationHighlight() + } + } + } + + @objc static func stopListeningPrimaryController(stopListenToRadialMenuButton: Bool = false) { + guard let primaryControllerButtonMap = primaryControllerElementMap else { return } + for gcElement in primaryControllerButtonMap.values { + if let gcButton = gcElement as? GCControllerButtonInput { + if #available(iOS 13.0, *) { + if !stopListenToRadialMenuButton, gcButton === primaryControllerButtonMap[ControllerNavigator.radialMenuButton] as? GCControllerButtonInput {continue} + } + gcButton.pressedChangedHandler = nil + } + if let gcStick = gcElement as? GCControllerDirectionPad { + gcStick.valueChangedHandler = nil + } + if let gcAxis = gcElement as? GCControllerAxisInput { + gcAxis.valueChangedHandler = nil + } + } + primaryControllerAxisStates.removeAll() + primaryGCController?.extendedGamepad?.valueChangedHandler = nil + } + + @objc static func listenPrimaryControllerButton(_ button: ControllerElement, ignoreSwapABXY: Bool = true, handler: @escaping (_ pressed: Bool) -> Void){ + if var elementMap = ControllerUtil.primaryControllerElementMap { + if ignoreSwapABXY { + elementMap[.a] = ControllerUtil.primaryGCController?.extendedGamepad?.buttonA + elementMap[.b] = ControllerUtil.primaryGCController?.extendedGamepad?.buttonB + elementMap[.x] = ControllerUtil.primaryGCController?.extendedGamepad?.buttonX + elementMap[.y] = ControllerUtil.primaryGCController?.extendedGamepad?.buttonY + } + if let gcButton = elementMap[button] as? GCControllerButtonInput { + gcButton.pressedChangedHandler = { _, _, pressed in + handler(pressed) + } + } + } + } + + @objc static func listenPrimaryControllerStick(_ stick: ControllerElement, handler: @escaping (_ offsetVector: CGVector) -> Void){ + if let elementMap = ControllerUtil.primaryControllerElementMap { + if let gcStick = elementMap[stick] as? GCControllerDirectionPad { + gcStick.valueChangedHandler = { _, xValue, yValue in + handler(CGVector(dx: CGFloat(xValue), dy: CGFloat(yValue))) + } + } + } + } + + @objc static func listenPrimaryControllerStickAxis(_ stickAxis: ControllerElement, threshold: CGFloat, handler: @escaping (_ state: ComparisonResult) -> Void) { + guard threshold > 0 else { return } + guard let elementMap = ControllerUtil.primaryControllerElementMap, + let gcAxis = elementMap[stickAxis] as? GCControllerAxisInput else { return } + + primaryControllerAxisStates[stickAxis] = stickAxisState(for: CGFloat(gcAxis.value), threshold: threshold) + gcAxis.valueChangedHandler = { _, value in + let newState = stickAxisState(for: CGFloat(value), threshold: threshold) + let previousState = primaryControllerAxisStates[stickAxis] ?? .orderedSame + guard newState != previousState else { return } + + primaryControllerAxisStates[stickAxis] = newState + handler(newState) + } + } + + private static func stickAxisState(for value: CGFloat, threshold: CGFloat) -> ComparisonResult { + if value > threshold { + return .orderedDescending + } + + if value < -threshold { + return .orderedAscending + } + + return .orderedSame + } + + + + + // MARK: - input processing + @objc static func compensated(offsetVector: CGVector, minOffset: CGFloat, circulate:Bool=false) -> CGVector{ let vectorHypot = hypot(offsetVector.dx, offsetVector.dy) guard vectorHypot > 0 else {return CGVector(dx: 0, dy: 0)} @@ -233,9 +565,11 @@ import Foundation } } +// MARK: - realtime overlay + @available(iOS 13.0, *) struct GamepadOverlaySnapshot: Equatable { - var pressedButtons: Set = [] + var pressedButtons: Set = [] var dpadHighlight: Int = 0 var leftStick: CGPoint = .zero var rightStick: CGPoint = .zero @@ -247,7 +581,7 @@ struct GamepadOverlaySnapshot: Equatable { init() {} init(gamepad: GCExtendedGamepad) { - var pressedButtons = Set() + var pressedButtons = Set() if gamepad.buttonA.isPressed { pressedButtons.insert(.a) } if gamepad.buttonB.isPressed { pressedButtons.insert(.b) } @@ -255,8 +589,8 @@ struct GamepadOverlaySnapshot: Equatable { if gamepad.buttonY.isPressed { pressedButtons.insert(.y) } if gamepad.leftShoulder.isPressed { pressedButtons.insert(.leftShoulder) } if gamepad.rightShoulder.isPressed { pressedButtons.insert(.rightShoulder) } - if gamepad.buttonMenu.isPressed { pressedButtons.insert(.menu) } - if gamepad.buttonOptions?.isPressed == true { pressedButtons.insert(.back) } + if gamepad.buttonMenu.isPressed { pressedButtons.insert(.start) } + if gamepad.buttonOptions?.isPressed == true { pressedButtons.insert(.select) } if #available(iOS 14.0, *), gamepad.buttonHome?.isPressed == true { pressedButtons.insert(.special) } if gamepad.leftThumbstickButton?.isPressed == true { pressedButtons.insert(.leftStickButton) } if gamepad.rightThumbstickButton?.isPressed == true { pressedButtons.insert(.rightStickButton) } diff --git a/VoidLink/Input/CustomOSC/OSCProfile.m b/VoidLink/Input/CustomOSC/OSCProfile.m index 5b72ebf0..209b6812 100644 --- a/VoidLink/Input/CustomOSC/OSCProfile.m +++ b/VoidLink/Input/CustomOSC/OSCProfile.m @@ -121,8 +121,8 @@ self.physicalRightStickMinOffset = [decoder containsValueForKey:@"physicalRightStickMinOffset"] ? [decoder decodeDoubleForKey:@"physicalRightStickMinOffset"] : 0; self.controllerGyroSwitchMode = [decoder containsValueForKey:@"controllerGyroSwitchMode"] ? [decoder decodeIntForKey:@"controllerGyroSwitchMode"] : ControllerGyroSwitchDisabled; self.reverseGyroHoldButton = [decoder containsValueForKey:@"reverseGyroHoldButton"] ? [decoder decodeBoolForKey:@"reverseGyroHoldButton"] : false; - self.controllerGyroSwitchHold = [decoder containsValueForKey:@"controllerGyroSwitchHold"] ? [decoder decodeIntForKey:@"controllerGyroSwitchHold"] : ControllerButtonNull; - self.controllerGyroSwitchToggle = [decoder containsValueForKey:@"controllerGyroSwitchToggle"] ? [decoder decodeIntForKey:@"controllerGyroSwitchToggle"] : ControllerButtonNull; + self.controllerGyroSwitchHold = [decoder containsValueForKey:@"controllerGyroSwitchHold"] ? [decoder decodeIntForKey:@"controllerGyroSwitchHold"] : ControllerElementNull; + self.controllerGyroSwitchToggle = [decoder containsValueForKey:@"controllerGyroSwitchToggle"] ? [decoder decodeIntForKey:@"controllerGyroSwitchToggle"] : ControllerElementNull; self.touchMode = [decoder containsValueForKey:@"touchMode"] ? [decoder decodeIntForKey:@"touchMode"] : NativeTouch; if(self.touchMode == NativeTouchOnly) self.touchMode = NativeTouch; diff --git a/VoidLink/Input/CustomOSC/OSCProfilesManager.swift b/VoidLink/Input/CustomOSC/OSCProfilesManager.swift index 569f5fbe..0016b808 100644 --- a/VoidLink/Input/CustomOSC/OSCProfilesManager.swift +++ b/VoidLink/Input/CustomOSC/OSCProfilesManager.swift @@ -329,7 +329,7 @@ class OSCProfilesManager: NSObject { return profiles[Int(selectedIndex)] as! OSCProfile - return (profiles.firstObject as? OSCProfile) ?? OSCProfile(name: "", buttonStates: NSMutableArray(), isSelected: false) + // return (profiles.firstObject as? OSCProfile) ?? OSCProfile(name: "", buttonStates: NSMutableArray(), isSelected: false) } func getIndexOfSelectedProfile() -> Int { @@ -401,8 +401,8 @@ class OSCProfilesManager: NSObject { normalizedPosition.x = position.x / Self.layoutViewBounds.size.width normalizedPosition.y = position.y / Self.layoutViewBounds.size.height } - NSLog("layoutToolView bounds: %f, %f", Self.layoutViewBounds.size.width, Self.layoutViewBounds.size.height) - NSLog("position: %f, %f, denormalized position: %f, %f", normalizedPosition.x, normalizedPosition.y, originalPosition.x, originalPosition.y) + // NSLog("layoutToolView bounds: %f, %f", Self.layoutViewBounds.size.width, Self.layoutViewBounds.size.height) + // NSLog("position: %f, %f, denormalized position: %f, %f", normalizedPosition.x, normalizedPosition.y, originalPosition.x, originalPosition.y) return normalizedPosition } diff --git a/VoidLink/Input/MotionHandler.swift b/VoidLink/Input/MotionHandler.swift index 4acc224c..01185b0e 100644 --- a/VoidLink/Input/MotionHandler.swift +++ b/VoidLink/Input/MotionHandler.swift @@ -151,7 +151,6 @@ import CoreMotion self.widgetYawFactor = yawFactor self.widgetPitchFactor = pitchFactor self.widgetRollFactor = rollFactor - print("self.widgetRollFactor \(self.widgetRollFactor)") if self.motionStarter == nil { self.motionStarter = sender if sender.motionControlButtonString != "GYROPAUSE" {self.startMotionUpdate()} diff --git a/VoidLink/Input/NativeTouchHandler.m b/VoidLink/Input/NativeTouchHandler.m index c1a19841..dc328fc6 100644 --- a/VoidLink/Input/NativeTouchHandler.m +++ b/VoidLink/Input/NativeTouchHandler.m @@ -11,6 +11,7 @@ #import "NativeTouchPointer.h" #import "OnScreenControls.h" #import "StreamView.h" +#import "VoidLink-Swift.h" #include diff --git a/VoidLink/Input/OnScreenWidgetView.swift b/VoidLink/Input/OnScreenWidgetView.swift index a8144694..7b0b117b 100644 --- a/VoidLink/Input/OnScreenWidgetView.swift +++ b/VoidLink/Input/OnScreenWidgetView.swift @@ -59,13 +59,11 @@ import ObjectiveC.runtime func openWidgetProfileTable(pickProfile: Bool) func bringUpSoftKeyboard() func alterAbsTouchDragWith(mouseButton:Int32) - func switchPencilHover() func enablePencilHover() func disablePencilHover() func setAllowSingleTouchEnabled(_ enabled:Bool) func replaceBrush(shortcut:String) func replaceEraser(shortcut:String) - func setPencilTilt(disabled:Bool) func presentPressureCurveVC() func toggleTouch(disabled:Bool) func toggleGamepadOverlay(overlayEnabled:Bool) @@ -1550,7 +1548,7 @@ import ObjectiveC.runtime private func sendLongMouseLeftButtonClickEvent() { DispatchQueue.global(qos: .userInteractive).async { // Logging the press event - NSLog("Sending left mouse button press") + // NSLog("Sending left mouse button press") LiSendMouseButtonEvent(CChar(BUTTON_ACTION_PRESS), BUTTON_LEFT) // Wait 200 ms to simulate a real button press @@ -1560,7 +1558,9 @@ import ObjectiveC.runtime LiSendMouseButtonEvent(CChar(BUTTON_ACTION_RELEASE), BUTTON_LEFT) // NSLog("double click: first long click release") } - else{NSLog("Left mouse button release cancelled, keep pressing down, turning into dragging...")} + else{ + // NSLog("Left mouse button release cancelled, keep pressing down, turning into dragging...") + } // Don't release the button if we're still dragging, this will prevent the dragging from being interrupted. } } diff --git a/VoidLink/Input/PureNativeTouchHandler.m b/VoidLink/Input/PureNativeTouchHandler.m index 21f9629f..a71ac8e7 100644 --- a/VoidLink/Input/PureNativeTouchHandler.m +++ b/VoidLink/Input/PureNativeTouchHandler.m @@ -11,6 +11,7 @@ #import "NativeTouchPointer.h" #import "OnScreenControls.h" #import "StreamView.h" +#import "VoidLink-Swift.h" #include @@ -337,4 +338,3 @@ } @end - diff --git a/VoidLink/Input/RadialMenuOverlayView.swift b/VoidLink/Input/RadialMenuOverlayView.swift index df25d593..932b0bed 100644 --- a/VoidLink/Input/RadialMenuOverlayView.swift +++ b/VoidLink/Input/RadialMenuOverlayView.swift @@ -2,7 +2,7 @@ // RadialMenuOverlayView.swift // VoidLink // -// Created by Codex on 2026/7/1. +// Created by True砖家 on 2026/7/1. // Copyright © 2026 True砖家 on Bilibili. All rights reserved. // @@ -11,60 +11,91 @@ import UIKit @available(iOS 13.0, *) final class RadialMenuOverlayView: UIView { + func getNavigationElements() -> [ControllerNavigationElement] { + var elements: [ControllerNavigationElement] = [] + elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .right ? .leftStick : .rightStick, action: "focusNavigation")) + return elements + } + private enum Metrics { - static let diameter: CGFloat = 260 - static let bottomInset: CGFloat = 92 + static let diameter: CGFloat = PublicUtils.isIPhone ? 200 : 260 + static let releaseDistanceEpsilon: CGFloat = 0.015 } private let selectionState = RadialMenuSelectionState() private var hostingController: UIHostingController? + private var themeObserver: NSObjectProtocol? + private var previousJoystickDistance: CGFloat? + private var isJoystickReturningToCenter = false + private var releaseLatchedSelectedIndex: Int? static var menuSectors: [RadialMenuSector] = [] init() { super.init(frame: .zero) setupView() + observeThemeChanges() } required init?(coder: NSCoder) { super.init(coder: coder) setupView() + observeThemeChanges() + } + + deinit { + removeThemeObserver() } static func presentInKeyWindow() -> RadialMenuOverlayView? { guard let window = keyWindow() else { return nil } - let overlayView = RadialMenuOverlayView() overlayView.translatesAutoresizingMaskIntoConstraints = false + CATransaction.begin() + CATransaction.setDisableActions(true) window.addSubview(overlayView) - NSLayoutConstraint.activate([ overlayView.widthAnchor.constraint(equalToConstant: Metrics.diameter), overlayView.heightAnchor.constraint(equalToConstant: Metrics.diameter), overlayView.centerXAnchor.constraint(equalTo: window.centerXAnchor), - overlayView.bottomAnchor.constraint(equalTo: window.safeAreaLayoutGuide.bottomAnchor, constant: -Metrics.bottomInset) + overlayView.centerYAnchor.constraint(equalTo: window.centerYAnchor) ]) - + CATransaction.commit() return overlayView } - func updateSelection(xOffset: Float, yOffset: Float) -> RadialMenuItem? { + func updateSelection(xOffset: CGFloat, yOffset: CGFloat) -> RadialMenuItem? { + guard Thread.isMainThread else { + return DispatchQueue.main.sync { + self.updateSelection(xOffset: xOffset, yOffset: yOffset) + } + } + let previousSelectedIndex = selectionState.selectedIndex + latchSelectionIfNeeded( + distance: Self.normalizedDistance(xOffset: xOffset, yOffset: yOffset), + selectedIndex: previousSelectedIndex + ) + selectionState.updateSelection(xOffset: xOffset, yOffset: yOffset) guard previousSelectedIndex != nil, selectionState.selectedIndex == nil else { return nil } - guard let previousSelectedIndex, - Self.menuSectors.indices.contains(previousSelectedIndex) else { + let actionSelectedIndex = releaseLatchedSelectedIndex ?? previousSelectedIndex + resetReleaseLatch() + + guard let actionSelectedIndex, + Self.menuSectors.indices.contains(actionSelectedIndex) else { return nil } - return Self.menuSectors[previousSelectedIndex].item + return Self.menuSectors[actionSelectedIndex].item } func dismiss() { + removeThemeObserver() hostingController?.willMove(toParent: nil) hostingController?.view.removeFromSuperview() hostingController?.removeFromParent() @@ -77,11 +108,7 @@ final class RadialMenuOverlayView: UIView { isUserInteractionEnabled = false clipsToBounds = false - let radialMenuView = RadialMenuView( - sectors: Self.menuSectors, - isTouchSelectionEnabled: false, - selectionState: selectionState - ) + let radialMenuView = makeRadialMenuView() let hostingController = UIHostingController(rootView: radialMenuView) hostingController.view.backgroundColor = .clear @@ -97,9 +124,80 @@ final class RadialMenuOverlayView: UIView { hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor), hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor) ]) + + GamepadNavigationIllustrationHud.updateNavigationElements(self.getNavigationElements()) + } + + private func observeThemeChanges() { + themeObserver = NotificationCenter.default.addObserver( + forName: Notification.Name(ThemeManager.ThemeDidChangeNotification), + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshTheme() + } + } + + private func removeThemeObserver() { + if let themeObserver { + NotificationCenter.default.removeObserver(themeObserver) + self.themeObserver = nil + } + } + + private func refreshTheme() { + hostingController?.rootView = makeRadialMenuView() + } + + private func latchSelectionIfNeeded(distance: CGFloat, selectedIndex: Int?) { + defer { previousJoystickDistance = distance } + + guard let previousJoystickDistance else { + return + } + + if distance + Metrics.releaseDistanceEpsilon < previousJoystickDistance { + if !isJoystickReturningToCenter { + releaseLatchedSelectedIndex = selectedIndex + isJoystickReturningToCenter = true + } + } else if distance > previousJoystickDistance + Metrics.releaseDistanceEpsilon { + resetReleaseLatch(keepingPreviousDistance: true) + } + } + + private func resetReleaseLatch(keepingPreviousDistance: Bool = false) { + if !keepingPreviousDistance { + previousJoystickDistance = nil + } + isJoystickReturningToCenter = false + releaseLatchedSelectedIndex = nil + } + + private func makeRadialMenuView() -> RadialMenuView { + RadialMenuView( + sectors: Self.menuSectors, + isTouchSelectionEnabled: false, + style: RadialMenuStyle.themed( + for: ThemeManager.userInterfaceStyle(), + accentColor: ThemeManager.appPrimaryColor + ), + selectionState: selectionState + ) } private static func keyWindow() -> UIWindow? { UIApplication.shared.windows.first(where: { $0.isKeyWindow }) ?? UIApplication.shared.windows.first } + + private static func normalizedDistance(xOffset: CGFloat, yOffset: CGFloat) -> CGFloat { + let x = max(min(xOffset, 1), -1) + let y = max(min(yOffset, 1), -1) + return hypot(x, y) + } + + func navigateByController(forward: Bool) {} + func navigateByController(downward: Bool) {} + func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) {} + func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) {} } diff --git a/VoidLink/Input/RelativeTouchHandler.m b/VoidLink/Input/RelativeTouchHandler.m index b5a78e3b..746f39e3 100644 --- a/VoidLink/Input/RelativeTouchHandler.m +++ b/VoidLink/Input/RelativeTouchHandler.m @@ -8,6 +8,7 @@ #import "RelativeTouchHandler.h" #import "DataManager.h" +#import "VoidLink-Swift.h" #include diff --git a/VoidLink/Input/SafeTimer.swift b/VoidLink/Input/SafeTimer.swift index 391a8696..ad6df02e 100644 --- a/VoidLink/Input/SafeTimer.swift +++ b/VoidLink/Input/SafeTimer.swift @@ -13,12 +13,15 @@ import QuartzCore final class SafeTimer: NSObject { private var timer: DispatchSourceTimer? private let timerQueue: DispatchQueue // 私有串行队列 + private let timerQueueKey = DispatchSpecificKey() private let userHandler: () -> Void private let interval: TimeInterval private let delay: TimeInterval private var shouldRunHandler: Bool = false // 控制逻辑上的暂停/恢复 private var isCleaned: Bool = false // 是否已经 clean + @objc private(set) var remainingMinimumRunCount: Int = 0 + private var shouldCleanAfterMinimumRuns: Bool = false @objc init(interval: TimeInterval = 1.0, delay: TimeInterval = 0, @@ -29,6 +32,7 @@ final class SafeTimer: NSObject { self.delay = delay self.userHandler = handler self.timerQueue = DispatchQueue(label: queueLabel) + self.timerQueue.setSpecific(key: timerQueueKey, value: 1) super.init() @@ -39,6 +43,12 @@ final class SafeTimer: NSObject { guard let self = self, !self.isCleaned else { return } if !self.shouldRunHandler { return } self.userHandler() + if self.remainingMinimumRunCount > 0 { + self.remainingMinimumRunCount -= 1 + } + if self.shouldCleanAfterMinimumRuns && self.remainingMinimumRunCount == 0 { + self.cleanOnTimerQueue(force: true) + } } t.resume() // timer 一开始就 resume,避免 suspend/resume 的计数问题 @@ -46,7 +56,7 @@ final class SafeTimer: NSObject { } deinit { - clean() + cleanForDeinit() } /// 开始逻辑上的计时 @@ -67,30 +77,71 @@ final class SafeTimer: NSObject { /// 重置下一次触发时间,并开始执行 handler @objc func restart() { + restart(minimumRunCount: 1) + } + + /// 重置下一次触发时间,并保证 clean 前至少执行 minimumRunCount 次 handler + @objc(restartWithMinimumRunCount:) + func restart(minimumRunCount: Int = 1) { timerQueue.async { [weak self] in guard let self = self, let t = self.timer, !self.isCleaned else { return } + self.remainingMinimumRunCount = max(0, minimumRunCount) + self.shouldCleanAfterMinimumRuns = false t.schedule(deadline: .now() + self.delay, repeating: self.interval) self.shouldRunHandler = true } } @objc func isRunning() -> Bool { - return shouldRunHandler; + if DispatchQueue.getSpecific(key: timerQueueKey) != nil { + return shouldRunHandler + } + + return timerQueue.sync { + shouldRunHandler && !isCleaned + } } - /// 彻底清理 timer,返回后保证 handler 不再执行 + /// 彻底清理 timer;如果 restart(minimumRunCount:) 尚未跑够次数,则延后到最低次数满足后清理 @objc func clean() { - if isCleaned { return } + if DispatchQueue.getSpecific(key: timerQueueKey) != nil { + cleanOnTimerQueue() + return + } + // 同步在 timerQueue 执行,保证已排队事件全部处理完(或跳过) timerQueue.sync { - self.isCleaned = true - self.shouldRunHandler = false + cleanOnTimerQueue() + } + } - if let t = self.timer { - t.setEventHandler {} // 清空 handler - t.cancel() // 取消 timer - self.timer = nil - } + private func cleanForDeinit() { + if DispatchQueue.getSpecific(key: timerQueueKey) != nil { + cleanOnTimerQueue(force: true) + return + } + + timerQueue.sync { + cleanOnTimerQueue(force: true) + } + } + + private func cleanOnTimerQueue(force: Bool = false) { + guard !isCleaned else { return } + if !force && shouldRunHandler && remainingMinimumRunCount > 0 { + shouldCleanAfterMinimumRuns = true + return + } + + isCleaned = true + shouldRunHandler = false + remainingMinimumRunCount = 0 + shouldCleanAfterMinimumRuns = false + + if let t = timer { + t.setEventHandler {} // 清空 handler + t.cancel() // 取消 timer + timer = nil } } } diff --git a/VoidLink/Input/StreamView.h b/VoidLink/Input/StreamView.h index 8a01770b..153f096f 100644 --- a/VoidLink/Input/StreamView.h +++ b/VoidLink/Input/StreamView.h @@ -13,9 +13,10 @@ #import "ControllerSupport.h" #import "OnScreenControls.h" -#import "VoidLink-Swift.h" #import "StreamConfiguration.h" +@class PencilHandler; + @protocol UserInteractionDelegate - (void)userInteractionBegan; @@ -29,11 +30,7 @@ @end -#if TARGET_OS_TV -@interface StreamView : UIView -#else -@interface StreamView : UIView -#endif +@interface StreamView : UIView @property (weak, nonatomic) UIView* streamFrameTopLayerView; @property (weak, nonatomic) id interactionDelegate; diff --git a/VoidLink/Input/StreamView.m b/VoidLink/Input/StreamView.m index ec05d3fb..116836bc 100644 --- a/VoidLink/Input/StreamView.m +++ b/VoidLink/Input/StreamView.m @@ -28,7 +28,11 @@ #import "StreamFrameViewController.h" -@interface StreamView() +#if TARGET_OS_TV +@interface StreamView() +#else +@interface StreamView() +#endif @property (weak, nonatomic) StreamFrameViewController* streamFrameVC; @end diff --git a/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents b/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents index 79e191c3..e11db447 100644 --- a/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents +++ b/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents @@ -10,6 +10,7 @@ + @@ -40,17 +41,19 @@ - + - - - + + + + + @@ -75,8 +78,8 @@ + - @@ -110,6 +113,8 @@ + + diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index 90ba95f0..18213fb4 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -3748,7 +3748,7 @@ }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "虛擬手把" } }, @@ -3858,19 +3858,19 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "修改布局" } }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "修改佈局" } }, "zh-HK" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "修改佈局" } } @@ -4325,6 +4325,35 @@ } } }, + "applications" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applications" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "應用" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "應用" + } + } + } + }, "Applications" : { "extractionState" : "manual", "localizations" : { @@ -5207,6 +5236,29 @@ } } }, + "Centered (PlayStation)" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "居中(PS手柄)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "居中(PS手把)" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "居中(PS手把)" + } + } + } + }, "Check the command and parameter." : { "extractionState" : "manual", "localizations" : { @@ -5771,6 +5823,52 @@ } } }, + "Controller Mouse" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄鼠标" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把滑鼠" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把滑鼠" + } + } + } + }, + "Controller Navigation" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄导航" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把導覽" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把導覽" + } + } + } + }, "controllerGyroSwitchButtonStackTip" : { "extractionState" : "manual", "localizations" : { @@ -5829,31 +5927,31 @@ } } }, - "controllerToMouseStackTip" : { + "controllerNavigationStackTip" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Long press to switch to mouse mode: %@\nStick for moving mouse pointer: %@\nMouse left button: %@\nMouse right button: %@" + "value" : "Local radial menu button: %@\nStreaming radial menu button: %@\nStick for moving mouse pointer: %@\nMouse left button: %@\nMouse right button: %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "长按切换鼠标模式: %@\n控制鼠标的摇杆: %@\n鼠标左键: %@\n鼠标右键: %@" + "value" : "App本地手柄菜单按键: %@\n串流手柄菜单按键: %@\n控制鼠标的摇杆: %@\n鼠标左键: %@\n鼠标右键: %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "長按切換滑鼠模式: %@\n控制滑鼠的搖桿: %@\n滑鼠左鍵: %@\n滑鼠右鍵: %@" + "value" : "App 本地手把選單按鈕: %@\n串流手把選單按鈕: %@\n控制滑鼠的搖桿: %@\n滑鼠左鍵: %@\n滑鼠右鍵: %@" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "長按切換滑鼠模式: %@\n控制滑鼠的搖桿: %@\n滑鼠左鍵: %@\n滑鼠右鍵: %@" + "value" : "App 本地手把選單按鈕: %@\n串流手把選單按鈕: %@\n控制滑鼠的搖桿: %@\n滑鼠左鍵: %@\n滑鼠右鍵: %@" } } } @@ -6405,6 +6503,29 @@ } } }, + "Disable tilt for apple pencil" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "禁用笔刷倾斜" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用筆刷傾斜" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用筆刷傾斜" + } + } + } + }, "Disable touch" : { "extractionState" : "manual", "localizations" : { @@ -6595,6 +6716,64 @@ } } }, + "doublePressToAddFavorite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double Press to Favorite" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "双击收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雙擊收藏" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "雙擊收藏" + } + } + } + }, + "doublePressToDelete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double Press to Delete" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "双击删除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雙擊刪除" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "雙擊刪除" + } + } + } + }, "doubleTapShortcutStackTip" : { "extractionState" : "manual", "localizations" : { @@ -6624,29 +6803,6 @@ } } }, - "Down" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "下" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "下" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "下" - } - } - } - }, "Down Arrow" : { "extractionState" : "manual", "localizations" : { @@ -6693,6 +6849,98 @@ } } }, + "DPadD" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字键下" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字鍵下" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字鍵下" + } + } + } + }, + "DPadL" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字键左" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字鍵左" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字鍵左" + } + } + } + }, + "DPadR" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字键右" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字鍵右" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字鍵右" + } + } + } + }, + "DPadU" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字键上" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字鍵上" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "十字鍵上" + } + } + } + }, "Drag and drop buttons onto this trash can to remove them from the interface" : { "extractionState" : "manual", "localizations" : { @@ -6848,7 +7096,7 @@ }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "一塊啥都不幹的擋板區域" } }, @@ -7494,6 +7742,35 @@ } } }, + "exit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exit" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "退出" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "退出" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "退出" + } + } + } + }, "Exit" : { "extractionState" : "manual", "localizations" : { @@ -7863,6 +8140,35 @@ } } }, + "focusNavigation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Focus Navigation" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "焦点导航" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "焦點導航" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "焦點導航" + } + } + } + }, "Folder" : { "extractionState" : "manual", "localizations" : { @@ -9137,6 +9443,35 @@ } } }, + "holdToReorder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hold to Reorder" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按住排序" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "按住排序" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "按住排序" + } + } + } + }, "Host information updated" : { "extractionState" : "manual", "localizations" : { @@ -9754,6 +10089,35 @@ } } }, + "launch" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Launch" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟動" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟動" + } + } + } + }, "Launch" : { "extractionState" : "manual", "localizations" : { @@ -9800,6 +10164,64 @@ } } }, + "launchPairWake" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Launch / Pair / Wake" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启动/配对/唤醒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟動/配對/喚醒" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟動/配對/喚醒" + } + } + } + }, + "leftButton" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Left Button" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "鼠标左键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠左鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠左鍵" + } + } + } + }, "Layout of the Default profile can not be changed" : { "extractionState" : "manual", "localizations" : { @@ -10013,29 +10435,6 @@ } } }, - "Left stick" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "左摇杆" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "左搖桿" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "左搖桿" - } - } - } - }, "Left stick pad" : { "extractionState" : "manual", "localizations" : { @@ -10336,6 +10735,75 @@ } } }, + "LStick" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "左摇杆" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "左搖桿" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "左搖桿" + } + } + } + }, + "LStickX" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "左摇杆X轴" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "左搖桿X軸" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "左搖桿X軸" + } + } + } + }, + "LStickY" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "左摇杆Y轴" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "左搖桿Y軸" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "左搖桿Y軸" + } + } + } + }, "Lsvpad" : { "extractionState" : "manual", "localizations" : { @@ -10567,29 +11035,6 @@ } } }, - "Map controller to mouse" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "手柄映射为鼠标" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "手柄映射為滑鼠" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "手柄映射為滑鼠" - } - } - } - }, "mapGyroToStackTip" : { "extractionState" : "manual", "localizations" : { @@ -10619,6 +11064,35 @@ } } }, + "menuNavigation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu Navigation" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "菜单导航" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選單導航" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "選單導航" + } + } + } + }, "metalRenderTip" : { "extractionState" : "manual", "localizations" : { @@ -10913,35 +11387,6 @@ } } }, - "mouseModeSwitchButtonTip" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Press a button for switching to controller mouse mode (long press for 1 second during streaming)\n\nNote: if you want to designate home button, double tap to pass it through to VoidLink app on iOS 26+." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "按下用于切换手柄鼠标模式的按键(串流时长按1秒切换)\n\n注意:如果需要指定 home 键,在 iOS 26+ 系统上需要双击将该按键透传至 VoidLink。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "按下用於切換手柄滑鼠模式的按鍵(串流時長按 1 秒切換)\n\n注意:如果需要指定 Home 鍵,在 iOS 26+ 系統上需要雙擊將該按鍵透傳至 VoidLink。" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "按下用於切換手柄滑鼠模式的按鍵(串流時長按 1 秒切換)。\n\n注意:如果需要指定 Home 鍵,在 iOS 26+ 系統上需要雙擊將該按鍵透傳至 VoidLink。" - } - } - } - }, "MousePad" : { "extractionState" : "manual", "localizations" : { @@ -10965,7 +11410,36 @@ } } }, - "Move the stick you wish to use for mouse control. Another stick will be used for vertical & horizontal scroll." : { + "moveCursor" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move Cursor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移动光标" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "移動游標" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "移動游標" + } + } + } + }, + "Move the stick you want to use for mouse control. The other stick will be used for vertical and horizontal scrolling" : { "extractionState" : "manual", "localizations" : { "zh-Hans" : { @@ -11011,6 +11485,29 @@ } } }, + "Navigation Settings" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导航设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "導航設定" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "導航設定" + } + } + } + }, "NetTestFailed" : { "extractionState" : "manual", "localizations" : { @@ -11161,6 +11658,35 @@ } } }, + "nextItem" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next Item" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一项" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一項" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一項" + } + } + } + }, "No" : { "extractionState" : "manual", "localizations" : { @@ -11489,6 +12015,35 @@ } } }, + "ok" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "確定" + } + } + } + }, "Ok" : { "extractionState" : "manual", "localizations" : { @@ -12726,7 +13281,7 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "触点灵敏度, 用于调节右半屏触点的移速, 进而实现(原神/崩坏星穹铁道)游戏内视角转动的灵敏度调节.详见在线文档." } }, @@ -12888,19 +13443,42 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "按下用于操作鼠标右键的按键" + "value" : "按下用于操作鼠标 [右键] 的按键" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "按下用於操作滑鼠右鍵的按鍵" + "value" : "按下用於操作滑鼠 「右\u001d\u001d鍵」 的按鍵" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "按下用於操作滑鼠右鍵的按鍵" + "value" : "按下用於操作滑鼠 「右\u001d\u001d鍵」 的按鍵" + } + } + } + }, + "Press the same or a different button for controller navigation during streaming." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请按下相同或不同的按键,作为串流时的手柄导航键。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請按下相同或不同的按鍵,作為串流時的手把導覽鍵。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "請按下相同或不同的按鍵,作為串流時的手掣導覽鍵。" } } } @@ -13049,6 +13627,35 @@ } } }, + "previousItem" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previous Item" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一项" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一項" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一項" + } + } + } + }, "Profile %@ duplicated from current layout" : { "extractionState" : "manual", "localizations" : { @@ -13377,6 +13984,35 @@ } } }, + "quitApp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quit App" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "退出应用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "結束應用" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "結束應用" + } + } + } + }, "Quitting App Failed" : { "extractionState" : "manual", "localizations" : { @@ -13446,6 +14082,64 @@ } } }, + "radialMenu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radial Menu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄菜单" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把選單" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把選單" + } + } + } + }, + "radialMenuButtonTip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please choose and press one of [Select, Start, bumpers, triggers, the Home button, or Xbox back paddles] as the radial menu button.\n\nNote: if you want to designate the Home button, double tap to pass it through to VoidLink app on iOS 26+." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请在 [Select, Start, 肩键, 板机, Home键, Xbox背键] 之中,选择并按下作为手柄导航键。\n\n注意:如果需要指定 Home 键,在 iOS 26+ 系统上需要双击将该按键透传至 VoidLink。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "請在 [Select、Start、LB/RB、LT/RT、Home 鍵、Xbox 背鍵] 之中,選擇並按下作為手把導覽鍵。\n\n注意:如果需要指定 Home 鍵,在 iOS 26+ 系統上需要雙擊將該按鍵透傳至 VoidLink。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "請在 [Select、Start、LB/RB、LT/RT、Home 鍵、Xbox 背鍵] 之中,選擇並按下作為手把導覽鍵。\n\n注意:如果需要指定 Home 鍵,在 iOS 26+ 系統上需要雙擊將該按鍵透傳至 VoidLink。" + } + } + } + }, "RB/R1" : { "extractionState" : "manual", "localizations" : { @@ -13492,6 +14186,35 @@ } } }, + "readTip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Read Tip" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看提示" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看提示" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看提示" + } + } + } + }, "redirectMicStackTip" : { "extractionState" : "manual", "localizations" : { @@ -13553,35 +14276,6 @@ } } }, - "relativeTouchSlideThresholdStackTip" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fixes mouse cursor jump on initial move of touchPad mode." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用于修复触控板模式下鼠标指针刚开始移动时会突然跳一下的问题。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "用於修復觸控板模式下滑鼠指標剛開始移動時會突然跳一下的問題。" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "用於修復觸控板模式下滑鼠指標剛開始移動時會突然跳一下的問題。" - } - } - } - }, "Remove" : { "extractionState" : "manual", "localizations" : { @@ -13985,6 +14679,35 @@ } } }, + "rightButton" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Right Button" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "鼠标右键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠右鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠右鍵" + } + } + } + }, "Right Button" : { "extractionState" : "manual", "localizations" : { @@ -14008,29 +14731,6 @@ } } }, - "Right stick" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "右摇杆" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "右搖桿" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "右搖桿" - } - } - } - }, "Right stick wheel" : { "extractionState" : "manual", "localizations" : { @@ -14146,6 +14846,75 @@ } } }, + "RStick" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "右摇杆" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "右搖桿" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "右搖桿" + } + } + } + }, + "RStickX" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "右摇杆X轴" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "右搖桿X軸" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "右搖桿X軸" + } + } + } + }, + "RStickY" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "右摇杆Y轴" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "右搖桿Y軸" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "右搖桿Y軸" + } + } + } + }, "Rsvpad" : { "extractionState" : "manual", "localizations" : { @@ -14774,6 +15543,35 @@ } } }, + "Setting added to favorite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setting added to favorite" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已添加到收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已加入收藏" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "已加入收藏" + } + } + } + }, "Settings" : { "extractionState" : "manual", "localizations" : { @@ -16037,6 +16835,29 @@ } } }, + "Theme" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主题" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "主题" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "主题" + } + } + } + }, "There are no changes to undo" : { "extractionState" : "manual", "localizations" : { @@ -16411,6 +17232,29 @@ } } }, + "TouchBtn" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "触控板按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "觸控板按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "觸控板按鍵" + } + } + } + }, "touchModeStackTip" : { "extractionState" : "manual", "localizations" : { @@ -16699,29 +17543,6 @@ } } }, - "Up" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "上" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "上" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "上" - } - } - } - }, "Up Arrow" : { "extractionState" : "manual", "localizations" : { @@ -16797,6 +17618,35 @@ } } }, + "verticalHorizontalScroll" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scroll" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "横纵滚动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "橫縱捲動" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "橫縱捲動" + } + } + } + }, "Velocity-based right stick pad" : { "extractionState" : "manual", "localizations" : { @@ -17209,6 +18059,29 @@ } } }, + "Which side of the controller is this button on?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此按键位于手柄的哪一侧?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此按鍵位於手把的哪一側?" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "此按鍵位於手掣的哪一側?" + } + } + } + }, "Widget config generated" : { "extractionState" : "manual", "localizations" : { @@ -17301,6 +18174,64 @@ } } }, + "widgetOperationBackward" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget Operation" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控件操作" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項操作" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項操作" + } + } + } + }, + "widgetOperationForward" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget Operation" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控件操作" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項操作" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項操作" + } + } + } + }, "widgetPanelLink" : { "extractionState" : "manual", "localizations" : { @@ -17583,4 +18514,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/VoidLink/MenuSectionView.swift b/VoidLink/MenuSectionView.swift index b76da701..88d0107d 100644 --- a/VoidLink/MenuSectionView.swift +++ b/VoidLink/MenuSectionView.swift @@ -51,7 +51,7 @@ class MenuSectionView: UIView { private var toggleButton: UIButton! private var toggleArea: UIButton! private var heightConstraint: NSLayoutConstraint! - private var headerView: UIView! + var headerView: UIView! private var titleOffset: CGFloat = 41.5 override init(frame: CGRect) { @@ -69,8 +69,9 @@ class MenuSectionView: UIView { layer.masksToBounds = true backgroundColor = .clear - headerView = UIView() + headerView = UIButton() headerView.translatesAutoresizingMaskIntoConstraints = false + headerView.accessibilityIdentifier = "sectionHeader" addSubview(headerView) iconImageView = UIImageView() @@ -273,6 +274,8 @@ class MenuSectionView: UIView { persistedFoldState = true } setExpanded(MenuSectionView.overridePersistedFoldState ? expandable : persistedFoldState) + + headerView.accessibilityIdentifier = "\(self.headerView.accessibilityIdentifier ?? "sectionHeader")-\(self.identifier ?? "")" } func removeSubStackView(_ stackView: UIStackView) { diff --git a/VoidLink/Stream/StreamManager.m b/VoidLink/Stream/StreamManager.m index 930073e4..9423718a 100644 --- a/VoidLink/Stream/StreamManager.m +++ b/VoidLink/Stream/StreamManager.m @@ -22,6 +22,7 @@ #import "HttpRequest.h" #import "IdManager.h" #import "LocalizationHelper.h" +#import "VoidLink-Swift.h" #include diff --git a/VoidLink/ThemeManager.swift b/VoidLink/ThemeManager.swift index 3f326c23..0238f005 100644 --- a/VoidLink/ThemeManager.swift +++ b/VoidLink/ThemeManager.swift @@ -36,6 +36,7 @@ class ThemeManager: NSObject { @objc class func setUserInterfaceStyle(_ style: UIUserInterfaceStyle) { _privateUserInterfaceStyle = style + applyUserInterfaceStyleOverride(style) if _userInterfaceStyle == style { return @@ -48,6 +49,27 @@ class ThemeManager: NSObject { object: nil ) } + + @objc class func applyUserInterfaceStyleOverride(_ style: UIUserInterfaceStyle) { + guard #available(iOS 13.0, *) else { return } + + let applyOverride = { + let sceneWindows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + + let allWindows = sceneWindows + UIApplication.shared.windows + Set(allWindows).forEach { window in + window.overrideUserInterfaceStyle = style + } + } + + if Thread.isMainThread { + applyOverride() + } else { + DispatchQueue.main.async(execute: applyOverride) + } + } @objc static var menuBackgroundColor: UIColor { _ = setPublicUIStyle() diff --git a/VoidLink/UIAppView.h b/VoidLink/UIAppView.h deleted file mode 100644 index 4bf31c45..00000000 --- a/VoidLink/UIAppView.h +++ /dev/null @@ -1,36 +0,0 @@ -// -// UIAppView.h -// Moonlight -// -// Created by Diego Waxemberg on 10/22/14. -// Copyright (c) 2014 Moonlight Stream. All rights reserved. -// - -#import -#import "TemporaryApp.h" - -@protocol AppViewUpdateLoopDelegate -- (bool)isInAppView; -@end - - -@protocol AppCallback - -- (void) appClicked:(TemporaryApp*)app view:(UIView*)view; -- (void) appLongClicked:(TemporaryApp*)app view:(UIView*)view; - -@end - -#if !TARGET_OS_TV -@interface UIAppView : UIButton -#else -@interface UIAppView : UIButton -#endif - - -@property (nonatomic, weak) id updateLoopDelegate; // Delegate property - -- (id) initWithApp:(TemporaryApp*)app cache:(NSCache*)cache andCallback:(id)callback; -- (void) updateAppImage; - -@end diff --git a/VoidLink/UIAppView.m b/VoidLink/UIAppView.m deleted file mode 100644 index 82aba6a2..00000000 --- a/VoidLink/UIAppView.m +++ /dev/null @@ -1,291 +0,0 @@ -// -// UIAppView.m -// Moonlight -// -// Created by Diego Waxemberg on 10/22/14. -// Copyright (c) 2014 Moonlight Stream. All rights reserved. -// -// Created by True砖家 on 2025.7.20. -// Copyright © 2025 True砖家 @ Bilibili. All rights reserved. -// - - -#import "UIAppView.h" -#import "AppAssetManager.h" -#import "VoidLink-Swift.h" - -static const float REFRESH_CYCLE = 1.0f; - -@implementation UIAppView { - TemporaryApp* _app; - UILabel* _appLabel; - UIImageView* _appOverlay; - UIImageView* _appImage; - NSCache* _artCache; - id _callback; -} - -static UIImage* noImage; - -- (id) initWithApp:(TemporaryApp*)app cache:(NSCache*)cache andCallback:(id)callback { - self = [super init]; - _app = app; - _callback = callback; - _artCache = cache; - - self.layer.cornerRadius = 16; - if (@available(iOS 13.0, *)) self.layer.cornerCurve = kCACornerCurveContinuous; - self.clipsToBounds = YES; - - // Cache the NoAppImage ourselves to avoid - // having to load it each time - if (noImage == nil) { - noImage = [UIImage imageNamed:@"NoAppImage"]; - } - -#if TARGET_OS_TV - self.frame = CGRectMake(0, 0, 200, 265); -#else - self.frame = CGRectMake(0, 0, 150, 200); -#endif - - [self setAlpha:app.hidden ? 0.4 : 1.0]; - - _appImage = [[UIImageView alloc] initWithFrame:self.frame]; - [_appImage setImage:noImage]; - - [self addSubview:_appImage]; - - // Use UIContextMenuInteraction on iOS 13.0+ and a standard UILongPressGestureRecognizer - // for tvOS devices and iOS prior to 13.0. -#if !TARGET_OS_TV - if (@available(iOS 13.0, *)) { - UIContextMenuInteraction* rightClickInteraction = [[UIContextMenuInteraction alloc] initWithDelegate:self]; - [self addInteraction:rightClickInteraction]; - } - else -#endif - { - UILongPressGestureRecognizer* longPressRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(appLongClicked:)]; - [self addGestureRecognizer:longPressRecognizer]; - } - - [self addTarget:self action:@selector(appClicked:) forControlEvents:UIControlEventPrimaryActionTriggered]; - - [self addTarget:self action:@selector(buttonSelected:) forControlEvents:UIControlEventTouchDown]; - [self addTarget:self action:@selector(buttonDeselected:) forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchCancel | UIControlEventTouchDragExit]; - -#if TARGET_OS_TV - _appImage.adjustsImageWhenAncestorFocused = YES; -#else - // Rasterizing the cell layer increases rendering performance by quite a bit - // but we want it unrasterized for tvOS where it must be scaled. - self.layer.shouldRasterize = NO; - // self.layer.rasterizationScale = [UIScreen mainScreen].scale; - - if (@available(iOS 13.4.1, *)) { - // Allow the button style to change when moused over - self.pointerInteractionEnabled = YES; - } -#endif - - [self updateAppImage]; - - return self; -} - -- (void)didMoveToSuperview { - // Start our update loop when we are added to our cell - if (self.superview != nil) { - [self updateLoop]; - } -} - -- (void) appClicked:(UIView *)view { - [_callback appClicked:_app view:view]; -} - -- (void) appLongClicked:(UILongPressGestureRecognizer*)gesture { - if (gesture.state == UIGestureRecognizerStateBegan) { - [_callback appLongClicked:_app view:self]; - } -} - -#if !TARGET_OS_TV -- (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction - configurationForMenuAtLocation:(CGPoint)location { - // We don't want to trigger the primary action at this point, so cancel - // tracking touch on this view now. This will also have the (intended) - // effect of removing the touch highlight on this view. - [self cancelTrackingWithEvent:nil]; - - [_callback appLongClicked:_app view:self]; - return nil; -} -#endif - -- (bool)isCurrentApp{ - return [_app.id isEqualToString:_app.host.currentGame]; -} - -- (void) updateAppImage { - - if (_appOverlay != nil) { - [_appOverlay removeFromSuperview]; - _appOverlay = nil; - } - if (_appLabel != nil) { - [_appLabel removeFromSuperview]; - _appLabel = nil; - } - - BOOL noAppImage = false; - - // First check the memory cache - //UIImage* appImage = [_artCache objectForKey:_app]; - UIImage* appImage = nil; - // NSLog(@"appImage instance: %lu", (uintptr_t)appImage); - if (appImage == nil) { - // Next try to load from the on disk cache - appImage = [UIImage imageWithContentsOfFile:[AppAssetManager boxArtPathForApp:_app]]; - if (appImage != nil) { - [_artCache setObject:appImage forKey:_app]; - } - } - - - // [_artCache setObject:appImage forKey:_app]; - - if (appImage != nil) { - // This size of image might be blank image received from GameStream. - - // TODO: Improve no-app image detection - if (!(appImage.size.width == 130.f && appImage.size.height == 180.f) && // GFE 2.0 - !(appImage.size.width == 628.f && appImage.size.height == 888.f)){ - - // if(true){ // GFE 3.0 - [_appImage setImage:appImage]; - } else { - noAppImage = true; - } - } else { - noAppImage = true; - } - - - if([self isCurrentApp]) { - // Only create the app overlay if needed - - if (@available(iOS 13.0, *)) { - UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:23]; - UIImageView* playIcon = [[UIImageView alloc] initWithImage:[[UIImage systemImageNamed:@"play.circle.fill" withConfiguration:config] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]]; - //playIcon.tintColor = [ThemeManager.widgetBackgroundColor colorWithAlphaComponent:0.85]; - playIcon.tintColor = [[UIColor blackColor] colorWithAlphaComponent:0.55]; - - _appOverlay = playIcon; - } else { - UIImageView* playIcon = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"play.circle.fill"]]; - playIcon.tintColor = [[UIColor blackColor] colorWithAlphaComponent:0.55]; - _appOverlay = playIcon; - - } - - - //_appOverlay.layer.shadowColor = [UIColor blackColor].CGColor; - //_appOverlay.layer.shadowOffset = CGSizeMake(1, 1); - //_appOverlay.layer.shadowOpacity = 1; - //_appOverlay.layer.shadowRadius = 1.3; - //_appOverlay.contentMode = UIViewContentModeScaleAspectFit; - } - else if(noAppImage){ - if (@available(iOS 13.0, *)) { - UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:70]; - UIImage* appIconImage = [[UIImage imageNamed:@"icon-pc-app"] imageWithConfiguration:config]; - UIImageView* appIcon = [[UIImageView alloc] initWithImage:[appIconImage imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]]; - - //layIcon.tintColor = [ThemeManager.widgetBackgroundColor colorWithAlphaComponent:0.85]; - appIcon.tintColor = [[UIColor blackColor] colorWithAlphaComponent:0.5]; - - _appOverlay = appIcon; - } - } - - if(true) { - _appLabel = [[UILabel alloc] init]; - _appLabel.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.55]; - [_appLabel setTextColor:[[UIColor whiteColor] colorWithAlphaComponent:1]]; - //_appLabel.shadowColor = [UIColor blackColor]; - [_appLabel setText:[_app.name isEqualToString:@"Steam Big Picture"] ? @"Steam" : _app.name]; - [_appLabel setFont:[UIFont systemFontOfSize:15]]; - [_appLabel setBaselineAdjustment:UIBaselineAdjustmentAlignCenters]; - [_appLabel setTextAlignment:NSTextAlignmentCenter]; - [_appLabel setLineBreakMode:NSLineBreakByWordWrapping]; - [_appLabel setNumberOfLines:2]; - _appLabel.lineBreakMode = NSLineBreakByTruncatingTail; - } - - [self positionSubviews]; - -#if TARGET_OS_TV - [_appImage.overlayContentView addSubview:_appLabel]; - [_appImage.overlayContentView addSubview:_appOverlay]; -#else - [self addSubview:_appOverlay]; - [self addSubview:_appLabel]; -#endif -} - -- (void) buttonSelected:(id)sender { - _appImage.layer.opacity = 0.5f; -} -- (void) buttonDeselected:(id)sender { - _appImage.layer.opacity = [self isCurrentApp] ? 0.75 : 1.0f; -} - -- (void) positionSubviews { - // CGFloat padding = 28; - CGFloat verticalPadding = 10; - CGSize frameSize = _appImage.frame.size; - // CGPoint center = _appImage.center; - - [_appLabel setFrame:CGRectMake(0, frameSize.height-43, frameSize.width, 43)]; - //[_appLabel setFrame:CGRectMake(0, 0, 30, 12)]; - - - if (_appOverlay != nil) { - _appOverlay.frame = [self isCurrentApp] ? CGRectMake(0, 0, frameSize.width / 2.39, frameSize.width / 2.39) : CGRectMake(0, 0, frameSize.width / 2, frameSize.width / 2); - _appOverlay.center = CGPointMake(frameSize.width/2, frameSize.height/2 - 2 * verticalPadding); - if([self isCurrentApp]) _appImage.layer.opacity = 0.75f; - - //[_appLabel setFrame:CGRectMake(padding, _appOverlay.frame.size.height + padding, frameSize.width - 2 * padding, frameSize.height - _appOverlay.frame.size.height - 2 * padding)]; - } -} - -- (void) updateLoop { - // Stop immediately if the view has been detached - if (self.superview == nil || ![self.updateLoopDelegate isInAppView]) { - return; - } - - // NSLog(@"appview update loop %f", CACurrentMediaTime()); - - // Update the app image if neccessary - if ((_appOverlay != nil && ![_app.id isEqualToString:_app.host.currentGame]) || - (_appOverlay == nil && [_app.id isEqualToString:_app.host.currentGame])) { - [self updateAppImage]; - } - - // Show no shadow for hidden apps. Because we adjust the opacity of the - // cells for hidden apps, it makes them look bad when the shadow draws - // through the app tile. - // self.superview.layer.shadowOpacity = _app.hidden ? 0.0f : 0.5f; - self.superview.layer.shadowOpacity = 0; - - // Update opacity if neccessary - [self setAlpha:_app.hidden ? 0.4 : 1.0]; - - // Queue the next refresh cycle - [self performSelector:@selector(updateLoop) withObject:self afterDelay:REFRESH_CYCLE]; -} - -@end diff --git a/VoidLink/UIAppView.swift b/VoidLink/UIAppView.swift new file mode 100644 index 00000000..7144da70 --- /dev/null +++ b/VoidLink/UIAppView.swift @@ -0,0 +1,260 @@ +// +// UIAppView.swift +// Moonlight +// +// Created by Diego Waxemberg on 10/22/14. +// Copyright (c) 2014 Moonlight Stream. All rights reserved. +// +// Created by True Zhuanjia on 2025.7.20. +// Copyright 2025 True Zhuanjia @ Bilibili. All rights reserved. +// + +import UIKit + +private let refreshCycle: TimeInterval = 1.0 + +@objc protocol AppViewUpdateLoopDelegate: AnyObject { + func isInAppView() -> Bool +} + +@objc protocol AppCallback: AnyObject { + @objc(appClicked:view:) + func appClicked(_ app: TemporaryApp, view: UIView) + + @objc(appLongClicked:view:) + func appLongClicked(_ app: TemporaryApp, view: UIView) +} + +@objcMembers +final class UIAppView: UIButton { + weak var updateLoopDelegate: AppViewUpdateLoopDelegate? + + private static var noImage: UIImage? + + let app: TemporaryApp + fileprivate weak var callback: AppCallback? + private let artCache: NSCache + private var appLabel: UILabel? + private var appOverlay: UIImageView? + private var appImage: UIImageView + private var contextMenuDelegate: AnyObject? + + @objc(initWithApp:cache:andCallback:) + init(app: TemporaryApp, cache: NSCache, andCallback callback: AppCallback) { + self.app = app + self.callback = callback + self.artCache = cache + + if UIAppView.noImage == nil { + UIAppView.noImage = UIImage(named: "NoAppImage") + } + + #if os(tvOS) + let initialFrame = CGRect(x: 0, y: 0, width: 200, height: 265) + #else + let initialFrame = CGRect(x: 0, y: 0, width: 150, height: 200) + #endif + + self.appImage = UIImageView(frame: initialFrame) + + super.init(frame: .zero) + + layer.cornerRadius = 16 + if #available(iOS 13.0, tvOS 13.0, *) { + layer.cornerCurve = .continuous + } + clipsToBounds = true + frame = initialFrame + alpha = app.hidden ? 0.4 : 1.0 + + appImage.image = UIAppView.noImage + addSubview(appImage) + + #if !os(tvOS) + if #available(iOS 13.0, *) { + let delegate = UIAppViewContextMenuDelegate(appView: self) + contextMenuDelegate = delegate + let rightClickInteraction = UIContextMenuInteraction(delegate: delegate) + addInteraction(rightClickInteraction) + } else { + let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(appLongClicked(_:))) + addGestureRecognizer(longPressRecognizer) + } + #else + let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(appLongClicked(_:))) + addGestureRecognizer(longPressRecognizer) + #endif + + addTarget(self, action: #selector(appClicked(_:)), for: .primaryActionTriggered) + addTarget(self, action: #selector(buttonSelected(_:)), for: .touchDown) + addTarget(self, action: #selector(buttonDeselected(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit]) + + #if os(tvOS) + appImage.adjustsImageWhenAncestorFocused = true + #else + layer.shouldRasterize = false + if #available(iOS 13.4, *) { + isPointerInteractionEnabled = true + } + #endif + + updateAppImage() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + if superview != nil { + updateLoop() + } + } + + @objc private func appClicked(_ view: UIView) { + callback?.appClicked(app, view: view) + } + + @objc private func appLongClicked(_ gesture: UILongPressGestureRecognizer) { + if gesture.state == .began { + callback?.appLongClicked(app, view: self) + } + } + + private var isCurrentApp: Bool { + app.id == app.host?.currentGame + } + + func updateAppImage() { + appOverlay?.removeFromSuperview() + appOverlay = nil + appLabel?.removeFromSuperview() + appLabel = nil + + var noAppImage = false + var loadedAppImage: UIImage? + + if let boxArtPath = AppAssetManager.boxArtPath(for: app) { + loadedAppImage = UIImage(contentsOfFile: boxArtPath) + if let loadedAppImage { + artCache.setObject(loadedAppImage, forKey: app) + } + } + + if let loadedAppImage { + let isGFE2BlankImage = loadedAppImage.size.width == 130.0 && loadedAppImage.size.height == 180.0 + let isGFE3BlankImage = loadedAppImage.size.width == 628.0 && loadedAppImage.size.height == 888.0 + if !isGFE2BlankImage && !isGFE3BlankImage { + appImage.image = loadedAppImage + } else { + noAppImage = true + } + } else { + noAppImage = true + } + + if isCurrentApp { + if #available(iOS 13.0, tvOS 13.0, *) { + let config = UIImage.SymbolConfiguration(pointSize: 23) + let image = UIImage(systemName: "play.circle.fill", withConfiguration: config)?.withRenderingMode(.alwaysTemplate) + let playIcon = UIImageView(image: image) + playIcon.tintColor = UIColor.black.withAlphaComponent(0.55) + appOverlay = playIcon + } else { + let playIcon = UIImageView(image: UIImage(named: "play.circle.fill")) + playIcon.tintColor = UIColor.black.withAlphaComponent(0.55) + appOverlay = playIcon + } + } else if noAppImage { + if #available(iOS 13.0, tvOS 13.0, *) { + let config = UIImage.SymbolConfiguration(pointSize: 70) + let image = UIImage(named: "icon-pc-app")?.withConfiguration(config).withRenderingMode(.alwaysTemplate) + let appIcon = UIImageView(image: image) + appIcon.tintColor = UIColor.black.withAlphaComponent(0.5) + appOverlay = appIcon + } + } + + let label = UILabel() + label.backgroundColor = UIColor.black.withAlphaComponent(0.55) + label.textColor = UIColor.white + label.text = app.name == "Steam Big Picture" ? "Steam" : app.name + label.font = UIFont.systemFont(ofSize: 15) + label.baselineAdjustment = .alignCenters + label.textAlignment = .center + label.lineBreakMode = .byTruncatingTail + label.numberOfLines = 2 + appLabel = label + + positionSubviews() + + #if os(tvOS) + appImage.overlayContentView.addSubview(label) + if let appOverlay { + appImage.overlayContentView.addSubview(appOverlay) + } + #else + if let appOverlay { + addSubview(appOverlay) + } + addSubview(label) + #endif + } + + @objc private func buttonSelected(_ sender: Any) { + appImage.layer.opacity = 0.5 + } + + @objc private func buttonDeselected(_ sender: Any) { + appImage.layer.opacity = isCurrentApp ? 0.75 : 1.0 + } + + private func positionSubviews() { + let verticalPadding: CGFloat = 10 + let frameSize = appImage.frame.size + + appLabel?.frame = CGRect(x: 0, y: frameSize.height - 43, width: frameSize.width, height: 43) + + guard let appOverlay else { return } + let overlaySize = isCurrentApp ? frameSize.width / 2.39 : frameSize.width / 2.0 + appOverlay.frame = CGRect(x: 0, y: 0, width: overlaySize, height: overlaySize) + appOverlay.center = CGPoint(x: frameSize.width / 2.0, y: frameSize.height / 2.0 - 2.0 * verticalPadding) + + if isCurrentApp { + appImage.layer.opacity = 0.75 + } + } + + @objc private func updateLoop() { + guard superview != nil, updateLoopDelegate?.isInAppView() == true else { + return + } + + if (appOverlay != nil && !isCurrentApp) || (appOverlay == nil && isCurrentApp) { + updateAppImage() + } + + superview?.layer.shadowOpacity = 0 + alpha = app.hidden ? 0.4 : 1.0 + + perform(#selector(updateLoop), with: self, afterDelay: refreshCycle) + } +} + +@available(iOS 13.0, *) +private final class UIAppViewContextMenuDelegate: NSObject, UIContextMenuInteractionDelegate { + private weak var appView: UIAppView? + + init(appView: UIAppView) { + self.appView = appView + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + guard let appView else { return nil } + appView.cancelTracking(with: nil) + appView.callback?.appLongClicked(appView.app, view: appView) + return nil + } +} diff --git a/VoidLink/Utility/PublicExtensions.swift b/VoidLink/Utility/PublicExtensions.swift index e7288c82..44a2a17d 100644 --- a/VoidLink/Utility/PublicExtensions.swift +++ b/VoidLink/Utility/PublicExtensions.swift @@ -8,6 +8,14 @@ import UIKit +extension UIViewController { + var hasNoPresentedVC: Bool { + let hasPresentedVC = self.presentedViewController == nil && self.view.window != nil + // let presentedVC = self.presentedViewController + return hasPresentedVC + } +} + public extension UIView { var parentViewController: UIViewController? { var responder: UIResponder? = self @@ -27,6 +35,15 @@ public extension CGPoint { } } +public extension Array where Element: Equatable { + @discardableResult + mutating func appendIfNotContains(_ newElement: Element) -> Bool { + guard !contains(newElement) else { return false } + append(newElement) + return true + } +} + public extension String { var localized: String { LocalizationHelper.localizedString(forKey: self) @@ -40,6 +57,35 @@ public extension String { } } +public extension UIFont { + static func roundedSystemFont(ofSize size: CGFloat, weight: UIFont.Weight) -> UIFont { + let font = UIFont.systemFont(ofSize: size, weight: weight) + if #available(iOS 13.0, *) { + if let roundedDescriptor = font.fontDescriptor.withDesign(.rounded) { + return UIFont(descriptor: roundedDescriptor, size: size) + } + } + + return font + } +} + +public extension UISlider { + func step(forward: Bool, visualStepRatio: Float) { + let range = self.maximumValue - self.minimumValue + guard range > 0 else { return } + + let step = range * visualStepRatio + let nextValue = self.value + (forward ? step : -step) + + let clampedValue = min(max(nextValue, self.minimumValue), self.maximumValue) + DispatchQueue.main.async { + self.setValue(clampedValue, animated: false) + self.sendActions(for: .valueChanged) + } + } +} + private var previousSelectedSegmentIndexKey: UInt8 = 0 private var lastKnownSelectedSegmentIndexKey: UInt8 = 0 public extension UISegmentedControl { @@ -104,7 +150,7 @@ public extension UISegmentedControl { let currentValue = selectedSegmentIndex if currentValue != newValue { - storePreviousSelectedSegmentIndex(newValue) + storePreviousSelectedSegmentIndex(currentValue) storeLastKnownSelectedSegmentIndex(newValue) } @@ -182,4 +228,10 @@ public extension UISegmentedControl { method_exchangeImplementations(originalMethod, swizzledMethod) } } + + func nextIndex(forward: Bool) -> Int { + var targetIndex = forward ? self.selectedSegmentIndex + 1 : self.selectedSegmentIndex - 1 + targetIndex = forward ? (targetIndex == self.numberOfSegments ? 0 : targetIndex) : (targetIndex == -1 ? self.numberOfSegments-1 : targetIndex) + return targetIndex + } } diff --git a/VoidLink/Utility/PublicUtils.swift b/VoidLink/Utility/PublicUtils.swift index 4bd74b2d..5f3be14e 100644 --- a/VoidLink/Utility/PublicUtils.swift +++ b/VoidLink/Utility/PublicUtils.swift @@ -86,6 +86,39 @@ import UIKit } } + @objc public static func controllerMouseSymbol() -> String { + if #available(iOS 17.0, tvOS 17.0, *) { + return "pointer.arrow.ipad" + } + else if #available(iOS 16.0, tvOS 16.0, *) { + return "pointer.arrow.ipad" + } + else { + return "paperplane" + } + } + + @objc public static func disableControllerMouseSymbol() -> String { + if #available(iOS 17.0, tvOS 17.0, *) { + return "pointer.arrow.slash.square" + } + else if #available(iOS 16.0, tvOS 16.0, *) { + return "pointer.arrow" + } + else { + return "paperplane" + } + } + + @objc public static func quitSymbol() -> String { + if #available(iOS 14.0, tvOS 14.0, *) { + return "pip.exit" + } + else { + return "arrow.left.square" + } + } + @objc public static func disconnectSymbolSize() -> CGFloat { if #available(iOS 26.0, tvOS 26.0, *) { return 15 @@ -175,6 +208,36 @@ import UIKit return rootViewController } + + public static func runOnMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + + @objc public static func controllerMouseExpoMappedOffset(_ offset: CGFloat, expo: CGFloat) -> CGFloat { + guard offset != 0 else { return 0 } + let magnitude = pow(abs(offset), expo) + return offset > 0 ? magnitude : -magnitude + } + + @objc public static func controllerMouseVelocity(pointerVelocity: CGFloat, frameRate: CGFloat) -> CGFloat { + guard frameRate > 0 else { return pointerVelocity } + return pointerVelocity * 60 / frameRate + } + + @objc public static func controllerMouseMoveVector(stickX: CGFloat, stickY: CGFloat, velocity: CGFloat, expo: CGFloat) -> CGVector { + return CGVector( + dx: velocity * controllerMouseExpoMappedOffset(stickX, expo: expo), + dy: -velocity * controllerMouseExpoMappedOffset(stickY, expo: expo) + ) + } + + @objc public static func controllerMouseScrollVector(stickX: CGFloat, stickY: CGFloat, multiplier: CGFloat) -> CGVector { + return CGVector(dx: multiplier * stickX, dy: multiplier * stickY) + } @objc(openUrl:) static func openUrl(_ urlString: String) { diff --git a/VoidLink/ViewControllers/CustomOSCViewControl/Table/OSCProfilesTableViewController.swift b/VoidLink/ViewControllers/CustomOSCViewControl/Table/OSCProfilesTableViewController.swift index 883307ac..77d12547 100644 --- a/VoidLink/ViewControllers/CustomOSCViewControl/Table/OSCProfilesTableViewController.swift +++ b/VoidLink/ViewControllers/CustomOSCViewControl/Table/OSCProfilesTableViewController.swift @@ -30,7 +30,8 @@ enum FileOperation: Int { } @objcMembers -final class OSCProfilesTableViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, UIDocumentPickerDelegate, UIGestureRecognizerDelegate { +final class OSCProfilesTableViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, UIDocumentPickerDelegate, UIGestureRecognizerDelegate, ControllerUINavigationDelegate { + @IBOutlet weak var tableView: UITableView! @IBOutlet weak var profileTableViewNavigationBar: UINavigationBar! @IBOutlet private weak var profileTableViewNavigationItem: UINavigationItem! @@ -44,6 +45,9 @@ final class OSCProfilesTableViewController: UIViewController, UITableViewDelegat private var profilesManager: OSCProfilesManager! private var horizontalConstraintsConfigured = false + private var profiles: NSMutableArray = [] + private var selectedProfileIndex = 0 + private var profilesLoadGeneration = 0 private func contentWidthMultiplier() -> CGFloat { PublicUtils.viewIsLandscape(view) ? (PublicUtils.isIPhone ? 0.8 : 0.65) : (PublicUtils.isIPhone ? 0.83 : 0.85) @@ -102,6 +106,13 @@ final class OSCProfilesTableViewController: UIViewController, UITableViewDelegat NotificationCenter.default.post(name: Notification.Name("OscLayoutTableViewCloseNotification"), object: self) } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if #available(iOS 13.0, *) { + ControllerNavigator.restorePreviousUINavigationDelegate(ifCurrentDelegateIs: self) + } + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() @@ -144,6 +155,8 @@ final class OSCProfilesTableViewController: UIViewController, UITableViewDelegat profileTableViewNavigationItem.leftBarButtonItems?.forEach { $0.hidesSharedBackground = true } profileTableViewNavigationItem.rightBarButtonItems?.forEach { $0.hidesSharedBackground = true } } + + reloadProfilesAsync(scrollToSelected: true) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -167,9 +180,17 @@ final class OSCProfilesTableViewController: UIViewController, UITableViewDelegat horizontalConstraintsConfigured = true } configureTableView() - if profilesManager.getAllProfiles().count > 0 { - let indexPath = IndexPath(row: profilesManager.getIndexOfSelectedProfile(), section: 0) - tableView.scrollToRow(at: indexPath, at: .middle, animated: true) + if profiles.count > 0 { + scrollToSelectedProfile(animated: true) + } else { + reloadProfilesAsync(scrollToSelected: true) + } + + if (loadingMode == .selectProfileFromMainFrame || loadingMode == .selectProfileFromStreamView) + && ControllerUtil.primaryGCController != nil { + if #available(iOS 13.0, *) { + ControllerNavigator.setUINavigationDelegate(self) + } } } @@ -180,6 +201,94 @@ final class OSCProfilesTableViewController: UIViewController, UITableViewDelegat tableView.register(UINib(nibName: "ProfileTableViewCell", bundle: nil), forCellReuseIdentifier: "Cell") } + private func reloadProfilesAsync(scrollToSelected: Bool = false, notify: Bool = false) { + profilesLoadGeneration += 1 + let generation = profilesLoadGeneration + let manager = profilesManager + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let manager else { return } + + let loadedProfiles = manager.getAllProfiles() + let loadedSelectedIndex = Self.selectedProfileIndex(in: loadedProfiles) + + DispatchQueue.main.async { [weak self] in + guard let self, generation == self.profilesLoadGeneration else { return } + + self.profiles = loadedProfiles + self.selectedProfileIndex = loadedSelectedIndex + self.tableView.reloadData() + + if scrollToSelected { + self.scrollToSelectedProfile(animated: true) + } + + if notify { + self.notifyProfileViewDidRefresh() + } + } + } + } + + private func scrollToSelectedProfile(animated: Bool) { + guard let selectedProfileIndex = validProfileIndex(selectedProfileIndex) else { + return + } + + let indexPath = IndexPath(row: selectedProfileIndex, section: 0) + tableView.scrollToRow(at: indexPath, at: .middle, animated: animated) + } + + private func moveSelectedProfile(by offset: Int) { + PublicUtils.runOnMain { [weak self] in + guard let self, + self.profiles.count > 0, + self.tableView.numberOfSections > 0, + self.tableView.numberOfRows(inSection: 0) > 0 else { + return + } + + let maxIndex = min(self.profiles.count, self.tableView.numberOfRows(inSection: 0)) - 1 + let currentIndex = min(max(self.selectedProfileIndex, 0), maxIndex) + let nextIndex = min(max(currentIndex + offset, 0), maxIndex) + let nextIndexPath = IndexPath(row: nextIndex, section: 0) + + self.tableView.selectRow(at: nextIndexPath, animated: true, scrollPosition: .middle) + self.tableView(self.tableView, didSelectRowAt: nextIndexPath) + } + } + + private func validProfileIndex(_ index: Int) -> Int? { + guard profiles.count > 0 else { return nil } + return min(max(index, 0), profiles.count - 1) + } + + private func notifyProfileViewDidRefresh() { + needToUpdateOscLayoutTVC?() + if loadingMode != .selectProfileFromStreamView + && loadingMode != .pickProfile + && loadingMode != .pickProfileData + && loadingMode != .selectProfile + { + guard let selectedProfileIndex = validProfileIndex(selectedProfileIndex) else { return } + NotificationCenter.default.post(name: Notification.Name("GameProfileSelectedNotification"), object: self.profiles[selectedProfileIndex]) + } + } + + private static func selectedProfileIndex(in profiles: NSMutableArray) -> Int { + for index in 0.. Int { - profilesManager.getAllProfiles().count + profiles.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as? ProfileTableViewCell, - let profile = profilesManager.getAllProfiles().object(at: indexPath.row) as? OSCProfile, + indexPath.row < profiles.count, + let profile = profiles.object(at: indexPath.row) as? OSCProfile, let nameLabel = cell.name else { return UITableViewCell() } @@ -395,7 +497,7 @@ final class OSCProfilesTableViewController: UIViewController, UITableViewDelegat cell.contentView.backgroundColor = UIColor.clear if loadingMode != .pickProfileData, - indexPath.row == profilesManager.getIndexOfSelectedProfile() { + indexPath.row == selectedProfileIndex { cell.accessoryType = UITableViewCell.AccessoryType.checkmark let checkmarkLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 20, height: 20)) checkmarkLabel.text = "✓" @@ -421,7 +523,7 @@ final class OSCProfilesTableViewController: UIViewController, UITableViewDelegat } func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - let profiles = profilesManager.getAllProfiles() + guard indexPath.row < profiles.count else { return } if let profile = profiles.object(at: indexPath.row) as? OSCProfile, profile.name == "Default" { let alert = UIAlertController(title: "", message: "Deleting the 'Default' profile is not allowed", preferredStyle: .alert) @@ -451,31 +553,58 @@ final class OSCProfilesTableViewController: UIViewController, UITableViewDelegat UserDefaults.standard.synchronize() } - tableView.reloadData() + reloadProfilesAsync() } } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.section == 0, + indexPath.row >= 0, + indexPath.row < profiles.count else { + return + } + if loadingMode == .pickProfileData, - let pickedProfile = profilesManager.getAllProfiles().object(at: indexPath.row) as? OSCProfile { + let pickedProfile = profiles.object(at: indexPath.row) as? OSCProfile { pickedProfileDataHandler?(pickedProfile) dismiss(animated: false) return } let selectedIndexPath = IndexPath(row: indexPath.row, section: 0) - let lastSelectedIndexPath = IndexPath(row: profilesManager.getIndexOfSelectedProfile(), section: 0) + let lastSelectedIndex = validProfileIndex(selectedProfileIndex) + let lastSelectedIndexPath = lastSelectedIndex.map { IndexPath(row: $0, section: 0) } if selectedIndexPath != lastSelectedIndexPath { - let selectedCell = tableView.cellForRow(at: selectedIndexPath) - selectedCell?.accessoryType = .checkmark - selectedCell?.accessoryView?.tintColor = UIColor(red: 0.5, green: 0.5, blue: 1.0, alpha: 1.0).withAlphaComponent(1.0) + if let lastSelectedIndexPath { + let previousCell = tableView.cellForRow(at: lastSelectedIndexPath) + previousCell?.accessoryType = .none + previousCell?.accessoryView = nil + } profilesManager.setProfileToSelected(UInt32(indexPath.row)) + selectedProfileIndex = indexPath.row - let lastSelectedCell = tableView.cellForRow(at: lastSelectedIndexPath) - lastSelectedCell?.accessoryType = .none - tableView.deselectRow(at: lastSelectedIndexPath, animated: true) + let selectedCell = tableView.cellForRow(at: selectedIndexPath) + selectedCell?.accessoryType = .checkmark + let checkmarkLabel = UILabel(frame: CGRect(x: 0, y: 0, width: 20, height: 20)) + checkmarkLabel.text = "✓" + checkmarkLabel.font = .systemFont(ofSize: 25) + checkmarkLabel.textAlignment = .center + checkmarkLabel.textColor = .green + checkmarkLabel.layer.zPosition = 0 + selectedCell?.accessoryView = checkmarkLabel + + let rowsToReload = [lastSelectedIndexPath, selectedIndexPath] + .compactMap { $0 } + .filter { + $0.section < tableView.numberOfSections && + $0.row >= 0 && + $0.row < tableView.numberOfRows(inSection: $0.section) + } + if !rowsToReload.isEmpty { + tableView.reloadRows(at: rowsToReload, with: .none) + } } profileViewRefresh() } @@ -491,4 +620,43 @@ final class OSCProfilesTableViewController: UIViewController, UITableViewDelegat } return touch.view == view } + + @available(iOS 13.0, *) + func getNavigationElements() -> [ControllerNavigationElement] { + var elements: [ControllerNavigationElement] = [] + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .y, action: "previousItem")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadDown : .a, action: "nextItem")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .x, action: "exit")) + return elements + } + + func navigateByController(forward: Bool) { + } + + func navigateByController(downward: Bool) { + + } + + func persistControllerNavigationHighlight() {} + func restoreControllerNavigationHighlight() {} + func restoreControllerNavigationHighlightAfterSettingsModeSwitch() {} + + func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) { + } + + func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) { + guard pressed else { return } + PublicUtils.runOnMain { [weak self] in + switch navigation.action { + case "previousItem": + self?.moveSelectedProfile(by: -1) + case "nextItem": + self?.moveSelectedProfile(by: 1) + case "exit": + self?.dismiss(animated: false) + default: + break + } + } + } } diff --git a/VoidLink/ViewControllers/HostCollectionViewController.swift b/VoidLink/ViewControllers/HostCollectionViewController.swift index 137be207..99129408 100644 --- a/VoidLink/ViewControllers/HostCollectionViewController.swift +++ b/VoidLink/ViewControllers/HostCollectionViewController.swift @@ -126,7 +126,6 @@ class HostCollectionViewController: UICollectionViewController, UICollectionView override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - NSLog("View did appear") } override func didMove(toParent parent: UIViewController?) { @@ -209,6 +208,7 @@ class HostCollectionViewController: UICollectionViewController, UICollectionView if let host = items[indexPath.item] as? TemporaryHost { cell.configure(with: host) } + applyControllerNavigationHighlight(to: cell, highlighted: indexPath == controllerNavigationSelectedIndexPath) return cell } diff --git a/VoidLink/ViewControllers/MainFrameViewController.h b/VoidLink/ViewControllers/MainFrameViewController.h index a002aafc..f8c29708 100644 --- a/VoidLink/ViewControllers/MainFrameViewController.h +++ b/VoidLink/ViewControllers/MainFrameViewController.h @@ -13,32 +13,43 @@ #import "DiscoveryManager.h" #import "PairManager.h" #import "StreamConfiguration.h" -#import "UIAppView.h" #import "AppAssetManager.h" #import "SWRevealViewController.h" -#import "VoidLink-Swift.h" +@class SettingsViewController; +@class TemporaryApp; +@class HostCollectionViewController; + +@interface MainFrameViewController : UICollectionViewController -@interface MainFrameViewController : UICollectionViewController @property (nonatomic, strong) IBOutlet UIBarButtonItem *settingsButton; @property (weak, nonatomic) IBOutlet UIBarButtonItem *profilesButton; +@property (weak, nonatomic) SettingsViewController *settingsViewController; +@property (nonatomic, strong) HostCollectionViewController *hostCollectionVC; +@property (nonatomic, strong, readonly) NSArray *sortedAppList; #if !TARGET_OS_TV @property (nonatomic, assign) bool settingsExpandedInStreamView; -@property (nonatomic, strong) HostCollectionViewController *hostCollectionVC; +@property (nonatomic, readonly) bool settingsViewExpanded; @property (weak, nonatomic) IBOutlet UINavigationItem *navigationItem; --(void)expandSettingsView; +- (void)expandSettingsView; - (void)closeSettingViewAnimated:(BOOL)anaimated; - (void)reloadStreamConfig; - (bool)isIPhonePortrait; -- (void)quitRunningApp; +- (bool)isInAppView; +- (bool)isStreaming; +- (TemporaryApp*)findRunningApp:(TemporaryHost*)host; +- (void)quitApp:(TemporaryApp* )app; +- (void)quitLaunchedApp; +- (void)launchApp:(TemporaryApp *)app; +- (void)quitRunningAppAndStart:(TemporaryApp *)app; + - (NSInteger)requestForBitrate:(NSInteger)bitrateKbps; #endif - (void)fillResolutionTable:(CGSize*)resolutionTable externalDisplayMode:(NSInteger)externalDisplayMode; -- (bool)isIPhone; - (void)setNeedsUpdateAllowedOrientation; @end diff --git a/VoidLink/ViewControllers/MainFrameViewController.m b/VoidLink/ViewControllers/MainFrameViewController.m index 003ad194..ce198231 100644 --- a/VoidLink/ViewControllers/MainFrameViewController.m +++ b/VoidLink/ViewControllers/MainFrameViewController.m @@ -16,7 +16,6 @@ #import "Connection.h" #import "StreamManager.h" #import "Utils.h" -#import "UIAppView.h" #import "DataManager.h" #import "TemporarySettings.h" #import "WakeOnLanManager.h" @@ -44,8 +43,10 @@ #include -@interface MainFrameViewController() +@interface MainFrameViewController() @property (weak, nonatomic) LayoutOnScreenControlsViewController* gameProfileSelectorVC; +@property (nonatomic, strong, readwrite) NSArray *sortedAppList; +@property (nonatomic, assign) bool settingsViewExpanded; @end @implementation MainFrameViewController { @@ -67,14 +68,11 @@ UIAlertController* _pairAlert; LoadingFrameViewController* _loadingFrame; FrontViewPosition currentPosition; - NSArray* _sortedAppList; NSCache* _boxArtCache; bool _background; bool _enteredAppView; - bool _settingsViewExpanded; UIView* menuSeparator; UIView* snapshot; - SettingsViewController* settingsViewController; __weak StreamFrameViewController* streamFrameViewController; id navBarAppearanceStandard; bool _viewJustAppeared; @@ -101,11 +99,13 @@ static NSMutableSet* hostList; message:[LocalizationHelper localizedStringForKey:@"Enter_PIN_Msg", PIN] preferredStyle:UIAlertControllerStyleAlert]; [self->_pairAlert addAction:[UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Cancel"] style:UIAlertActionStyleDestructive handler:^(UIAlertAction* action) { - self->_pairAlert = nil; - [self->_discMan startDiscovery]; - [self hideLoadingFrame: ^{ - [self switchToHostView]; - }]; + dispatch_async(dispatch_get_main_queue(), ^{ + self->_pairAlert = nil; + [self->_discMan startDiscovery]; + [self hideLoadingFrame: ^{ + [self switchToHostView]; + }]; + }); }]]; [[self activeViewController] presentViewController:self->_pairAlert animated:YES completion:nil]; }); @@ -348,7 +348,7 @@ static NSMutableSet* hostList; [_appManager stopRetrieving]; _showHiddenApps = NO; _selectedHost = nil; - _sortedAppList = nil; + self.sortedAppList = nil; // [self.collectionView removeFromSuperview]; // necessary for new scroll host view reloading mechanism self.hostCollectionVC.view.hidden = NO; @@ -356,6 +356,15 @@ static NSMutableSet* hostList; [self updateTitle]; self.navigationItem.rightBarButtonItems = @[_helpButton, _addHostButton]; self.revealViewController.mainFrameIsInHostView = true; // to allow orientation change only in app view, tell top view controller the mainframe is not in host view + + if (@available(iOS 13.0, *)){ + [GamepadNavigationIllustrationHud showInKeyWindow]; + [ControllerNavigator setUINavigationDelegate:self.isInAppView ? self.hostCollectionVC : self.hostCollectionVC]; + if(ControllerNavigator.radialMenuView.superview) [ControllerNavigator updateRadialMenu]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [ControllerNavigator restoreUINavigationHighlight]; + }); + } } - (void) receivedAssetForApp:(TemporaryApp*)app { @@ -378,6 +387,10 @@ static NSMutableSet* hostList; } - (void)switchToAppView{ + if (@available(iOS 13.0, *)) { + [ControllerNavigator setUINavigationDelegate:self]; + } + _enteredAppView = true; //_appManager = [[AppAssetManager alloc] initWithCallback:self]; [self.collectionView setCollectionViewLayout:self.collectionViewLayout]; @@ -399,6 +412,12 @@ static NSMutableSet* hostList; // self.navigationController.navigationBar.backgroundColor = [ThemeManager appBackgroundColor]; // self.navigationController.navigationBar.translucent = NO; //[self applyNavBarAppearance:navBarAppearance]; + + + dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)); + dispatch_after(delayTime, dispatch_get_main_queue(), ^{ + if(@available(iOS 13.0, *)) if(ControllerUtil.primaryGCController) [ControllerNavigator restoreUINavigationHighlight]; + }); } - (void)appButtonTappedForHost:(TemporaryHost *)host{ @@ -411,12 +430,13 @@ static NSMutableSet* hostList; } - (void)launchButtonTappedForHost:(TemporaryHost *)host { + if(self.revealViewController.isStreaming) return; _selectedHost = host; if (host.state == StateOnline && host.pairState == PairStatePaired && host.appList.count > 0) { [self closeSettingViewAnimated:NO]; // [self switchToAppView]; [self updateAppsForHost:_selectedHost]; - [self prepareToStreamApp:_sortedAppList.firstObject]; + [self prepareToStreamApp:self.sortedAppList.firstObject]; [self performSegueWithIdentifier:@"createStreamFrame" sender:nil]; return; } @@ -755,14 +775,16 @@ static NSMutableSet* hostList; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ [self->_discMan discoverHost:hostAddress withCallback:^(TemporaryHost* host, NSString* error){ if (host != nil) { - dispatch_async(dispatch_get_main_queue(), ^{ - [self hideLoadingFrame:^{ - @synchronized(hostList) { - [hostList addObject:host]; - } - [self updateHosts]; - }]; - }); + [alertController dismissViewControllerAnimated:false completion:^{ + dispatch_async(dispatch_get_main_queue(), ^{ + [self hideLoadingFrame:^{ + @synchronized(hostList) { + [hostList addObject:host]; + } + [self updateHosts]; + }]; + }); + }]; } else { unsigned int portTestResults = LiTestClientConnectivity([host.activeAddress UTF8String], 443, ML_PORT_FLAG_TCP_47984 | ML_PORT_FLAG_TCP_47989); @@ -774,13 +796,15 @@ static NSMutableSet* hostList; [Utils addHelpOptionToDialog:hostNotFoundAlert]; [hostNotFoundAlert addAction:[UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Ok"] style:UIAlertActionStyleDefault handler:nil]]; dispatch_async(dispatch_get_main_queue(), ^{ - [self hideLoadingFrame:^{ - if([error isEqualToString:[LocalizationHelper localizedStringForKey:@"Host information updated"]]){ - DataManager* dataMan = [[DataManager alloc] init]; - [dataMan updateHost:host]; - } - [[self activeViewController] presentViewController:hostNotFoundAlert animated:YES completion:nil]; - }]; + [alertController dismissViewControllerAnimated:true completion:^{ + [self hideLoadingFrame:^{ + if([error isEqualToString:[LocalizationHelper localizedStringForKey:@"Host information updated"]]){ + DataManager* dataMan = [[DataManager alloc] init]; + [dataMan updateHost:host]; + } + [self presentViewController:hostNotFoundAlert animated:YES completion:nil]; + }]; + }]; }); } }]; @@ -990,10 +1014,11 @@ static NSMutableSet* hostList; return quitResponse; } -- (void)quitRunningApp{ +- (void)quitApp:(TemporaryApp* )app{ + if(!app) return; [self showLoadingFrame: ^{ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - HttpResponse* quitResponse = [self requestToQuitApp:self->launchedApp]; + HttpResponse* quitResponse = [self requestToQuitApp:app]; // If it fails, display an error and stop the current operation if (quitResponse.statusCode != 200) { UIAlertController* alert = [UIAlertController alertControllerWithTitle:[LocalizationHelper localizedStringForKey:@"Quitting App Failed"] @@ -1001,7 +1026,7 @@ static NSMutableSet* hostList; preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Ok"] style:UIAlertActionStyleDefault handler:nil]]; dispatch_async(dispatch_get_main_queue(), ^{ - [self updateAppsForHost:self->launchedApp.host]; + [self updateAppsForHost:app.host]; [self hideLoadingFrame: ^{ [[self activeViewController] presentViewController:alert animated:YES completion:nil]; }]; @@ -1012,6 +1037,54 @@ static NSMutableSet* hostList; }]; } +- (void)quitLaunchedApp { + [self quitApp:launchedApp]; +} + +- (void)quitRunningAppAndStart:(TemporaryApp *)app { + TemporaryApp* currentRunningApp = [self findRunningApp:app.host]; + [self showLoadingFrame: ^{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + HttpResponse* quitResponse = [self requestToQuitApp:app]; + // If it fails, display an error and stop the current operation + if (quitResponse.statusCode != 200) { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:[LocalizationHelper localizedStringForKey:@"Quitting App Failed"] + message:[LocalizationHelper localizedStringForKey:@"Failed to quit app. If this app was started by another device, you'll need to quit from that device."] + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:[LocalizationHelper localizedStringForKey:@"Ok"] style:UIAlertActionStyleDefault handler:nil]]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateAppsForHost:app.host]; + [self hideLoadingFrame: ^{ + [[self activeViewController] presentViewController:alert animated:YES completion:nil]; + }]; + }); + } + else { + app.host.currentGame = @"0"; + dispatch_async(dispatch_get_main_queue(), ^{ + // If it succeeds and we're to start streaming, segue to the stream + if (![app.id isEqualToString:currentRunningApp.id]) { + [self prepareToStreamApp:app]; + [self hideLoadingFrame: ^{ + [self performSegueWithIdentifier:@"createStreamFrame" sender:nil]; + }]; + } + else { + // Otherwise, just hide the loading icon + [self hideLoadingFrame:nil]; + } + }); + } + }); + }]; +} + +- (void)launchApp:(TemporaryApp *)app { + if(self.revealViewController.isStreaming) return; + [self prepareToStreamApp:app]; + [self performSegueWithIdentifier:@"createStreamFrame" sender:nil]; +} + - (void)appLongClicked:(TemporaryApp *)app view:(UIView *)view { Log(LOG_D, @"Long clicked app: %@", app.name); @@ -1188,15 +1261,15 @@ static NSMutableSet* hostList; countdown:6 action:^{} completion:^{ - [self openGameProfileSeletor:false]; + [self openGameProfileSeletorWithAnimated:false]; }]; } - else [self openGameProfileSeletor:false]; + else [self openGameProfileSeletorWithAnimated:false]; } -- (void)openGameProfileSeletor:(bool)animated { - if(settingsViewController){ - [settingsViewController mainFrameGameProfileButtonTapped:animated]; +- (void)openGameProfileSeletorWithAnimated:(bool)animated { + if(self.settingsViewController){ + [self.settingsViewController mainFrameGameProfileButtonTapped:animated]; return; } @@ -1235,11 +1308,17 @@ static NSMutableSet* hostList; } - (void)revealController:(SWRevealViewController *)revealController willMoveToPosition:(FrontViewPosition)position { - settingsViewController = (SettingsViewController*)[revealController rearViewController]; - revealController.navBarMenuDelegate = settingsViewController; + self.settingsViewController = (SettingsViewController*)[revealController rearViewController]; + revealController.navBarMenuDelegate = self.settingsViewController; + _settingsViewExpanded = position != FrontViewPositionLeft; if (position == FrontViewPositionLeft) { - if (@available(iOS 26.0, *)) { + if (@available(iOS 13.0, *)){ + self.settingsExpandedInStreamView = false; + if(!self.revealViewController.isStreaming) [ControllerNavigator setUINavigationDelegate: self.isInAppView ? self : self.hostCollectionVC]; + else [ControllerNavigator setUINavigationDelegate: [StreamFrameViewController sharedInstance]]; + } + if (@available(iOS 26.0, *)) { _settingsButton.sharesBackground = false; _profilesButton.sharesBackground = false; } @@ -1255,11 +1334,13 @@ static NSMutableSet* hostList; } } else { + if(self.revealViewController.isStreaming) self.settingsExpandedInStreamView = true; //notify mainFrameViewContorller that this is a setting expansion in stream view, some settings shall be disabled. + if (@available(iOS 13.0, *)) [ControllerNavigator setUINavigationDelegate:self.settingsViewController]; self.navigationItem.leftBarButtonItems = @[_profilesButton]; - [settingsViewController updateTheme]; + [self.settingsViewController updateTheme]; } - settingsViewController.mainFrameViewController = self; + self.settingsViewController.mainFrameViewController = self; // enable / disable widgets acoordingly: in streamview, disable, outside of streamview, enable. if(self.settingsExpandedInStreamView) [revealController buttonsInStreaming]; else [revealController buttonsNotInStreaming]; @@ -1268,71 +1349,72 @@ static NSMutableSet* hostList; // TemporarySettings* currentSettings = [dataMan getSettings]; [streamFrameViewController setUserInteractionEnabledForStreamView:!_settingsExpandedInStreamView || position == FrontViewPositionLeft]; - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.resolutionStack]; - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.fpsStack]; - // [settingsViewController widget:settingsViewController.bitrateSlider setEnabled:!self.settingsExpandedInStreamView]; - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.optimizeGamesStack]; - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.audioOnPcStack]; - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.sdrPerformanceWorkaroundStack]; - // [settingsViewController.touchModeSelector1 setEnabled:!_settingsExpandedInStreamView || !(settingsViewController.touchModeSelector1.selectedSegmentIndex == AbsoluteTouch && !settingsViewController.passthroughGesturesSwitch.isOn)]; - // [settingsViewController.touchModeSelector2 setEnabled:settingsViewController.touchModeSelector1.enabled]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.resolutionStack]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.fpsStack]; + // [self.settingsViewController widget:self.settingsViewController.bitrateSlider setEnabled:!self.settingsExpandedInStreamView]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.optimizeGamesStack]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.audioOnPcStack]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.sdrPerformanceWorkaroundStack]; + // [self.settingsViewController.touchModeSelector1 setEnabled:!_settingsExpandedInStreamView || !(self.settingsViewController.touchModeSelector1.selectedSegmentIndex == AbsoluteTouch && !self.settingsViewController.passthroughGesturesSwitch.isOn)]; + // [self.settingsViewController.touchModeSelector2 setEnabled:self.settingsViewController.touchModeSelector1.enabled]; - [settingsViewController.codecSelector setEnabled:!_settingsExpandedInStreamView]; + [self.settingsViewController.codecSelector setEnabled:!_settingsExpandedInStreamView]; if(_settingsExpandedInStreamView){ - [settingsViewController.yuv444Switch setEnabled:NO]; - [settingsViewController.fullColorRangeSwitch setEnabled:NO]; - [settingsViewController.hdrSwitch setEnabled:NO]; + [self.settingsViewController.yuv444Switch setEnabled:NO]; + [self.settingsViewController.fullColorRangeSwitch setEnabled:NO]; + [self.settingsViewController.hdrSwitch setEnabled:NO]; } - else [settingsViewController updateCodecDependentSwitches]; + else [self.settingsViewController updateCodecDependentSwitches]; - [settingsViewController.gyroModeSelector setEnabled:!_settingsExpandedInStreamView || ![streamFrameViewController shallDisableGyroHotSwitch]]; - [settingsViewController.emulatedControllerTypeSelector setEnabled:!_settingsExpandedInStreamView]; - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.citrixX1MouseStack]; - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.externalDisplayModeStack]; + [self.settingsViewController.gyroModeSelector setEnabled:!_settingsExpandedInStreamView || ![streamFrameViewController shallDisableGyroHotSwitch]]; + [self.settingsViewController.emulatedControllerTypeSelector setEnabled:!_settingsExpandedInStreamView]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.citrixX1MouseStack]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.externalDisplayModeStack]; - if(settingsViewController.audioConfigSelector.numberOfSegments>2){ + if(self.settingsViewController.audioConfigSelector.numberOfSegments>2){ if(_settingsExpandedInStreamView){ - if(settingsViewController.audioConfigSelector.selectedSegmentIndex>=2){ - [settingsViewController.audioConfigSelector setEnabled:false]; + if(self.settingsViewController.audioConfigSelector.selectedSegmentIndex>=2){ + [self.settingsViewController.audioConfigSelector setEnabled:false]; } else { - [settingsViewController.audioConfigSelector setEnabled:false forSegmentAtIndex:2]; - [settingsViewController.audioConfigSelector setEnabled:false forSegmentAtIndex:3]; + [self.settingsViewController.audioConfigSelector setEnabled:false forSegmentAtIndex:2]; + [self.settingsViewController.audioConfigSelector setEnabled:false forSegmentAtIndex:3]; } } else{ - [settingsViewController.audioConfigSelector setEnabled:true]; - [settingsViewController.audioConfigSelector setEnabled:true forSegmentAtIndex:2]; - [settingsViewController.audioConfigSelector setEnabled:true forSegmentAtIndex:3]; + [self.settingsViewController.audioConfigSelector setEnabled:true]; + [self.settingsViewController.audioConfigSelector setEnabled:true forSegmentAtIndex:2]; + [self.settingsViewController.audioConfigSelector setEnabled:true forSegmentAtIndex:3]; } } - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.duckOtherAppStack]; - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.pipStack]; - [settingsViewController setHidden:_settingsExpandedInStreamView forStack:settingsViewController.appThemeStack]; - [settingsViewController.renderingBackendSelector setEnabled:!_settingsExpandedInStreamView]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.duckOtherAppStack]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.pipStack]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.appThemeStack]; + [self.settingsViewController.renderingBackendSelector setEnabled:!_settingsExpandedInStreamView]; // Enable frame pacing mode selector only if not in stream view AND not in performance mode - BOOL shouldEnableFramePacingSelector = !_settingsExpandedInStreamView && (settingsViewController.renderingBackendSelector.selectedSegmentIndex != RENDER_METAL); - [settingsViewController.framePacingModeSelector setEnabled:shouldEnableFramePacingSelector]; - // [settingsViewController.frameTimebaseSwitch setEnabled:shouldEnableFramePacing]; - [settingsViewController.asyncFrameDequeueSwitch setEnabled:shouldEnableFramePacingSelector]; - [settingsViewController setHidden:_settingsExpandedInStreamView || !(shouldEnableFramePacingSelector && settingsViewController.framePacingModeSelector.selectedSegmentIndex == FramePacingModeQueue) forStack:settingsViewController.frameQueueSizeStack]; + BOOL shouldEnableFramePacingSelector = !_settingsExpandedInStreamView && (self.settingsViewController.renderingBackendSelector.selectedSegmentIndex != RENDER_METAL); + [self.settingsViewController.framePacingModeSelector setEnabled:shouldEnableFramePacingSelector]; + // [self.settingsViewController.frameTimebaseSwitch setEnabled:shouldEnableFramePacing]; + [self.settingsViewController.asyncFrameDequeueSwitch setEnabled:shouldEnableFramePacingSelector]; + [self.settingsViewController setHidden:_settingsExpandedInStreamView || !(shouldEnableFramePacingSelector && self.settingsViewController.framePacingModeSelector.selectedSegmentIndex == FramePacingModeQueue) forStack:self.settingsViewController.frameQueueSizeStack]; // Disable mic switch if sunshine does not support mic redirection - [settingsViewController.redirectMicSwitch setEnabled:!_settingsExpandedInStreamView||streamFrameViewController.micStreamInitialized]; - if(_settingsExpandedInStreamView && !streamFrameViewController.micStreamInitialized) [settingsViewController.redirectMicSwitch setOn:false]; - [settingsViewController setHidden:!settingsViewController.redirectMicSwitch.isOn forStack:settingsViewController.useBuiltinMicStack]; - [settingsViewController.useBuiltinMicSwitch setEnabled:!_settingsExpandedInStreamView]; - // [settingsViewController.passthroughGesturesSwitch setEnabled:!_settingsExpandedInStreamView]; + [self.settingsViewController.redirectMicSwitch setEnabled:!_settingsExpandedInStreamView||streamFrameViewController.micStreamInitialized]; + if(_settingsExpandedInStreamView && !streamFrameViewController.micStreamInitialized) [self.settingsViewController.redirectMicSwitch setOn:false]; + [self.settingsViewController setHidden:!self.settingsViewController.redirectMicSwitch.isOn forStack:self.settingsViewController.useBuiltinMicStack]; + [self.settingsViewController.useBuiltinMicSwitch setEnabled:!_settingsExpandedInStreamView]; + // [self.settingsViewController.passthroughGesturesSwitch setEnabled:!_settingsExpandedInStreamView]; } - (void)revealController:(SWRevealViewController *)revealController didMoveToPosition:(FrontViewPosition)position { // If we moved back to the center position, we should save the settings - SettingsViewController* settingsViewController = (SettingsViewController*)[revealController rearViewController]; - settingsViewController.mainFrameViewController = self; + self.settingsViewController = (SettingsViewController*)[revealController rearViewController]; + self.settingsViewController.mainFrameViewController = self; if (position == FrontViewPositionLeft) { - [settingsViewController saveSettings]; + if (@available(iOS 13.0, *)) [ControllerNavigator persistUINavigationHighlight]; + [self.settingsViewController saveSettings]; _settingsButton.enabled = YES; // make sure these 2 buttons are enabled after closing setting view. _upButton.enabled = YES; // here is the select new host button } @@ -1343,7 +1425,7 @@ static NSMutableSet* hostList; #if TARGET_OS_TV - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { - [self appClicked:_sortedAppList[indexPath.row] view:nil]; + [self appClicked:self.sortedAppList[indexPath.row] view:nil]; } #endif @@ -1446,10 +1528,6 @@ static NSMutableSet* hostList; } -- (bool)isIPhone{ - return ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone); -} - - (bool)isIPhonePortrait{ bool isIPhone = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone); CGFloat screenHeightInPoints = CGRectGetHeight([[UIScreen mainScreen] bounds]); @@ -1492,7 +1570,9 @@ static NSMutableSet* hostList; button.titleLabel.font = [UIFont systemFontOfSize:liquidGlassEnabled ? 16 : 16 weight:UIFontWeightMedium]; // 文字颜色设置为 tintColor 控制 - if(liquidGlassEnabled) button.titleEdgeInsets = UIEdgeInsetsMake(0, 0, 0.9, 0); + if(liquidGlassEnabled) { + button.titleEdgeInsets = UIEdgeInsetsMake(0, 0, 0.9, 0); + } button.tintColor = liquidGlassEnabled ? ThemeManager.appPrimaryColor : UIColor.whiteColor; [button setTitleColor:button.tintColor forState:UIControlStateNormal]; // button.tintColor = UIColor.whiteColor; @@ -1511,7 +1591,7 @@ static NSMutableSet* hostList; // 创建 UIBarButtonItem UIBarButtonItem *barItem = [[UIBarButtonItem alloc] initWithCustomView:button]; - + if (@available(iOS 26.0, *)) barItem.sharesBackground = true; return barItem; } @@ -1548,6 +1628,9 @@ static NSMutableSet* hostList; // 创建 UIBarButtonItem UIBarButtonItem *barItem = [[UIBarButtonItem alloc] initWithCustomView:button]; + + if (@available(iOS 26.0, *)) barItem.sharesBackground = true; + return barItem; } @@ -1574,6 +1657,60 @@ static NSMutableSet* hostList; } } +- (void)applyThemeToNavigationButton:(UIBarButtonItem *)barButtonItem { + if (!barButtonItem) return; + + barButtonItem.tintColor = ThemeManager.appPrimaryColor; + + if (@available(iOS 13.0, *)) { + UIUserInterfaceStyle style = ThemeManager.userInterfaceStyle; + UIView *customView = barButtonItem.customView; + customView.overrideUserInterfaceStyle = style; + + if ([customView isKindOfClass:UIButton.class]) { + UIButton *button = (UIButton *)customView; + button.tintColor = ThemeManager.appPrimaryColor; + [button setTitleColor:button.tintColor forState:UIControlStateNormal]; + [button setTitleColor:ThemeManager.textColorGray forState:UIControlStateHighlighted]; + } + } + + if (barButtonItem == _addHostButton && [barButtonItem.customView isKindOfClass:UIButton.class]) { + UIButton *button = (UIButton *)barButtonItem.customView; + button.backgroundColor = PublicUtils.liquidGlassEnabled ? UIColor.clearColor : ThemeManager.appPrimaryColor; + button.tintColor = PublicUtils.liquidGlassEnabled ? ThemeManager.appPrimaryColor : UIColor.whiteColor; + [button setTitleColor:button.tintColor forState:UIControlStateNormal]; + } +} + +- (void)applyThemeToNavigationControls { + if (@available(iOS 13.0, *)) { + UIUserInterfaceStyle style = ThemeManager.userInterfaceStyle; + self.overrideUserInterfaceStyle = style; + self.view.overrideUserInterfaceStyle = style; + self.navigationController.overrideUserInterfaceStyle = style; + self.navigationController.view.overrideUserInterfaceStyle = style; + self.navigationController.navigationBar.overrideUserInterfaceStyle = style; + self.navigationItem.titleView.overrideUserInterfaceStyle = style; + } + + NSMutableArray *barButtonItems = [NSMutableArray array]; + if (self.navigationItem.leftBarButtonItems) [barButtonItems addObjectsFromArray:self.navigationItem.leftBarButtonItems]; + if (self.navigationItem.rightBarButtonItems) [barButtonItems addObjectsFromArray:self.navigationItem.rightBarButtonItems]; + if (_settingsButton) [barButtonItems addObject:_settingsButton]; + if (_profilesButton) [barButtonItems addObject:_profilesButton]; + if (_addHostButton) [barButtonItems addObject:_addHostButton]; + if (_helpButton) [barButtonItems addObject:_helpButton]; + if (_upButton) [barButtonItems addObject:_upButton]; + + for (UIBarButtonItem *barButtonItem in barButtonItems) { + [self applyThemeToNavigationButton:barButtonItem]; + if (@available(iOS 26.0, *)) { + barButtonItem.sharesBackground = barButtonItem == _addHostButton || barButtonItem == _helpButton; + } + } +} + - (void)setupNavBar{ if (@available(iOS 13.0, *)) { UINavigationBarAppearance* appearance = [[UINavigationBarAppearance alloc] init]; @@ -1645,14 +1782,15 @@ static NSMutableSet* hostList; _profilesButton.tintColor = ThemeManager.appPrimaryColor; } - } else { [_profilesButton setTitle:[LocalizationHelper localizedStringForKey:@"Game Profile"]]; } if (@available(iOS 26.0, *)) { - _settingsButton.sharesBackground = false; - _profilesButton.sharesBackground = false; + _settingsButton.sharesBackground = false; + _profilesButton.sharesBackground = false; + _addHostButton.sharesBackground = true; + _helpButton.sharesBackground = true; } @@ -1692,15 +1830,20 @@ static NSMutableSet* hostList; } _settingsButton.tintColor = ThemeManager.appPrimaryColor; + _profilesButton.tintColor = ThemeManager.appPrimaryColor; _upButton.tintColor = ThemeManager.appPrimaryColor; ((UIButton*)_addHostButton.customView).backgroundColor = PublicUtils.liquidGlassEnabled ? UIColor.clearColor : ThemeManager.appPrimaryColor; ((UIButton*)_helpButton.customView).tintColor = ThemeManager.appPrimaryColor; + [self applyThemeToNavigationControls]; [self applyNavBarAppearance]; [self updateTitle]; if (hostViewTitleLabel) { hostViewTitleLabel.textColor = ThemeManager.textColor; } + + if (@available(iOS 13.0, *)) [GamepadNavigationIllustrationHud updateCurrentTheme]; + [self.hostCollectionVC updateTheme]; } @@ -1847,8 +1990,8 @@ static NSMutableSet* hostList; //[self simulateSettingsButtonPress]; //force expand setting view if orientation changed since last quit from app. //[self updateResolutionAccordingly]; - // SettingsViewController* settingsViewController = (SettingsViewController*)[self.revealViewController rearViewController]; - // [settingsViewController updateResolutionTable]; + // SettingsViewController* self.settingsViewController = (SettingsViewController*)[self.revealViewController rearViewController]; + // [self.settingsViewController updateResolutionTable]; UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleMenuResize:)]; longPress.delaysTouchesBegan = false; @@ -1856,7 +1999,7 @@ static NSMutableSet* hostList; [self.view addGestureRecognizer:longPress]; - UIStoryboard *storyboard = [UIStoryboard storyboardWithName:[self isIPhone]?@"iPhone":@"iPad" bundle:nil]; + UIStoryboard *storyboard = [UIStoryboard storyboardWithName:PublicUtils.isIPhone?@"iPhone":@"iPad" bundle:nil]; SettingsViewController *viewController = [storyboard instantiateViewControllerWithIdentifier:@"settingsViewController"]; // 强制加载视图 __unused UIView *view = viewController.view; @@ -1927,6 +2070,7 @@ static NSMutableSet* hostList; -(void)viewDidLayoutSubviews{ [super viewDidLayoutSubviews]; [self updateHosts]; + // if (@available(iOS 13.0, *)) [GamepadNavigationIllustrationHud showInKeyWindow]; } // this will also be called back when device orientation changes @@ -2007,7 +2151,7 @@ static NSMutableSet* hostList; CGPoint point = [gestureRecognizer locationInView:self.collectionView]; NSIndexPath *indexPath = [self.collectionView indexPathForItemAtPoint:point]; if (indexPath != nil) { - [self appLongClicked:_sortedAppList[indexPath.row] view:nil]; + [self appLongClicked:self.sortedAppList[indexPath.row] view:nil]; } } @@ -2099,7 +2243,8 @@ static NSMutableSet* hostList; // [self setupHostViewTitle]; // [self reloadScrollHostView]; //remove this for proper test [self attachWaterMark]; - + if (@available(iOS 13.0, *)) [GamepadNavigationIllustrationHud showInKeyWindow]; + #if !TARGET_OS_TV [[self revealViewController] setPrimaryViewController:self]; @@ -2133,13 +2278,26 @@ static NSMutableSet* hostList; if([self needPopupAboutView])[self helpButtonTapped]; if (@available(iOS 13.0, *)) { - [ControllerNavigator startWithSwapABXY:false]; + ControllerUtil.delegate = self; + [ControllerUtil installControllerObserversIfNeeded]; [ControllerNavigator setRadialMenuDelegate:self]; + DataManager* dataMan = [[DataManager alloc] init]; + Settings* settings = [dataMan retrieveSettings]; + ControllerNavigator.enabled = settings.enableControllerNavigation; + ControllerNavigator.localRadialMenuButton = (ControllerElement)settings.localRadialMenuButton.intValue; + ControllerNavigator.customPositionForLocalRadialMenuButton = (ControllerElementPosition)settings.customLocalRadialMenuButtonPosition.intValue; + ControllerNavigator.customPositionForStreamingRadialMenuButton = (ControllerElementPosition)settings.customStreamingRadialMenuButtonPosition.intValue; + ControllerNavigator.streamingRadialMenuButton = (ControllerElement)settings.streamingRadialMenuButton.intValue; + ControllerNavigator.streamingRadialMenuDelay = (NSTimeInterval)settings.streamingRadialMenuDelay.floatValue; + ControllerNavigator.controllerMouseStick = (ControllerElement)settings.controllerMouseStick.intValue; + ControllerNavigator.controllerMouseLeftButton = (ControllerElement)settings.controllerMouseLeftButton.intValue; + ControllerNavigator.controllerMouseRightButton = (ControllerElement)settings.controllerMouseRightButton.intValue; + ControllerNavigator.controllerMouseExpo = settings.controllerMouseExpo.floatValue; + if(ControllerNavigator.enabled) [ControllerNavigator start]; } } - (void)viewWillDisappear:(BOOL)animated{ - NSLog(@"willDisappear"); [super viewWillDisappear:animated]; [_foregroundHostUpdateTimer invalidate]; _foregroundHostUpdateTimer = nil; @@ -2177,6 +2335,7 @@ static NSMutableSet* hostList; [self.view addSubview:self.collectionView]; [self initHostCollection]; if(!_enteredAppView) [self switchToHostView]; + else if (@available(iOS 13.0, *)) [ControllerNavigator setUINavigationDelegate:self]; [self updateTheme]; } @@ -2343,17 +2502,17 @@ static NSMutableSet* hostList; return; } - _sortedAppList = [host.appList allObjects]; - _sortedAppList = [_sortedAppList sortedArrayUsingSelector:@selector(compareName:)]; + self.sortedAppList = [host.appList allObjects]; + self.sortedAppList = [self.sortedAppList sortedArrayUsingSelector:@selector(compareName:)]; if (!_showHiddenApps) { NSMutableArray* visibleAppList = [NSMutableArray array]; - for (TemporaryApp* app in _sortedAppList) { + for (TemporaryApp* app in self.sortedAppList) { if (!app.hidden) { [visibleAppList addObject:app]; } } - _sortedAppList = visibleAppList; + self.sortedAppList = visibleAppList; } [self.collectionView reloadData]; @@ -2370,7 +2529,7 @@ static NSMutableSet* hostList; - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { UICollectionViewCell* cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"AppCell" forIndexPath:indexPath]; - TemporaryApp* app = _sortedAppList[indexPath.row]; + TemporaryApp* app = self.sortedAppList[indexPath.row]; UIAppView* appView = [[UIAppView alloc] initWithApp:app cache:_boxArtCache andCallback:self]; appView.updateLoopDelegate = (id)self; @@ -2404,16 +2563,16 @@ static NSMutableSet* hostList; layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { CGSize cellSize; - if([self isIPhone]) cellSize.height = 0.365*MIN(CGRectGetHeight([[UIScreen mainScreen] bounds]),CGRectGetWidth([[UIScreen mainScreen] bounds])); + if(PublicUtils.isIPhone) cellSize.height = 0.365*MIN(CGRectGetHeight([[UIScreen mainScreen] bounds]),CGRectGetWidth([[UIScreen mainScreen] bounds])); else cellSize.height = 0.272*MIN(CGRectGetHeight([[UIScreen mainScreen] bounds]),CGRectGetWidth([[UIScreen mainScreen] bounds])); - TemporaryApp* app = _sortedAppList[indexPath.row]; + TemporaryApp* app = self.sortedAppList[indexPath.row]; UIAppView* appView = [[UIAppView alloc] initWithApp:app cache:_boxArtCache andCallback:self]; cellSize.width = cellSize.height * (appView .bounds.size.width/appView .bounds.size.height); // cardSize.width = - + return cellSize; } @@ -2422,8 +2581,8 @@ static NSMutableSet* hostList; } - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { - if (_selectedHost != nil && _sortedAppList != nil) { - return _sortedAppList.count; + if (_selectedHost != nil && self.sortedAppList != nil) { + return self.sortedAppList.count; } else { return 0; @@ -2498,7 +2657,7 @@ static NSMutableSet* hostList; double delayInSeconds = 0.02; dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); dispatch_after(popTime, dispatch_get_main_queue(), ^{ - [self->settingsViewController hideDynamicLabelsWhenOverlapped:self->settingsViewController.parentStack]; + [self.settingsViewController hideDynamicLabelsWhenOverlapped:self.settingsViewController.parentStack]; }); } } @@ -2573,7 +2732,7 @@ static NSMutableSet* hostList; - (CGSize)getHostCardSize{ CGSize cardSize; - if([self isIPhone]) cardSize.height = 0.37*MIN(CGRectGetHeight([[UIScreen mainScreen] bounds]),CGRectGetWidth([[UIScreen mainScreen] bounds])); + if(PublicUtils.isIPhone) cardSize.height = 0.37*MIN(CGRectGetHeight([[UIScreen mainScreen] bounds]),CGRectGetWidth([[UIScreen mainScreen] bounds])); else cardSize.height = 0.25*MIN(CGRectGetHeight([[UIScreen mainScreen] bounds]),CGRectGetWidth([[UIScreen mainScreen] bounds])); TemporaryHost* dummyHost = [[TemporaryHost alloc] init]; HostCardView* dummyCard = [[HostCardView alloc] initWithHost:dummyHost]; @@ -2593,7 +2752,7 @@ static NSMutableSet* hostList; if(self.hostCollectionVC.view.superview == nil){ [self.view addSubview:self.hostCollectionVC.view]; - CGFloat leftPadding = [self isIPhone] ? 30 : 0; + CGFloat leftPadding = PublicUtils.isIPhone ? 30 : 0; self.hostCollectionVC.view.translatesAutoresizingMaskIntoConstraints = NO; [NSLayoutConstraint activateConstraints:@[ [self.hostCollectionVC.view.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:0], @@ -2617,6 +2776,53 @@ static NSMutableSet* hostList; } } +- (void)controllerNavigatorDidSelectWithItem:(RadialMenuItem)item API_AVAILABLE(ios(13.0)){ + dispatch_async(dispatch_get_main_queue(), ^{ + switch (item) { + case RadialMenuItemSettings: + [[self revealViewController] revealToggleAnimated:YES]; + break; + case RadialMenuItemAllSettings: + [self.revealViewController allSettingSelected]; + break; + case RadialMenuItemFavoriteSettings: + [self.revealViewController favoriteSettingSelected]; + break; + case RadialMenuItemGameProfiles: + if([GenericUtils isFirstTappingGameProfileSelectorFromMainFrame]){ + DataManager* dataMan = [[DataManager alloc] init]; + Settings* settings = [dataMan retrieveSettings]; + NSString* edgeSide = settings.slideToSettingsScreenEdge.intValue != UIRectEdgeLeft ? [LocalizationHelper localizedStringForKey:@"left"] : [LocalizationHelper localizedStringForKey:@"right"]; + NSString* slideDist = [NSString stringWithFormat:@"%d%%", (int)(settings.slideToSettingsDistance.floatValue*100)]; + [AlertControllerUtil showAlertIn:self + title:[LocalizationHelper localizedStringForKey:@"Game Profile"] + message:[LocalizationHelper localizedStringForKey:@"gameProfileIntroduction", edgeSide, slideDist] + withCancel:NO + buttonTitle:[LocalizationHelper localizedStringForKey:@"Got it!"] + countdown:6 + action:^{} + completion:^{ + if(!self.gameProfileSelectorVC && !self.settingsViewController.layoutOnScreenControlsVC) [self openGameProfileSeletorWithAnimated:true]; + }]; + } + else if(!self.gameProfileSelectorVC && !self.settingsViewController.layoutOnScreenControlsVC) [self openGameProfileSeletorWithAnimated:true]; + else if (self.gameProfileSelectorVC) [self.gameProfileSelectorVC.oscProfilesTableViewController dismissViewControllerAnimated:true completion:^{}]; + else if (self.settingsViewController.layoutOnScreenControlsVC) [self.settingsViewController.layoutOnScreenControlsVC.oscProfilesTableViewController dismissViewControllerAnimated:true completion:^{}]; + break; + case RadialMenuItemHostView: + [self switchToHostView]; + break; + case RadialMenuItemAddHost: + [self addHostTapped]; + break; + case RadialMenuItemAboutView: + [self helpButtonTapped]; + break; + default: + break; + } + }); +} - (void)controllerNavigatorDidSelectSettings { dispatch_async(dispatch_get_main_queue(), ^{ @@ -2626,9 +2832,9 @@ static NSMutableSet* hostList; - (void)controllerNavigatorDidSelectGameProfiles { dispatch_async(dispatch_get_main_queue(), ^{ - if(!self.gameProfileSelectorVC && !self->settingsViewController.layoutOnScreenControlsVC) [self openGameProfileSeletor:true]; + if(!self.gameProfileSelectorVC && !self.settingsViewController.layoutOnScreenControlsVC) [self openGameProfileSeletorWithAnimated:true]; else if (self.gameProfileSelectorVC) [self.gameProfileSelectorVC.oscProfilesTableViewController dismissViewControllerAnimated:true completion:^{}]; - else if (self->settingsViewController.layoutOnScreenControlsVC) [self->settingsViewController.layoutOnScreenControlsVC.oscProfilesTableViewController dismissViewControllerAnimated:true completion:^{}]; + else if (self.settingsViewController.layoutOnScreenControlsVC) [self.settingsViewController.layoutOnScreenControlsVC.oscProfilesTableViewController dismissViewControllerAnimated:true completion:^{}]; }); } @@ -2640,4 +2846,3 @@ static NSMutableSet* hostList; } @end - diff --git a/VoidLink/ViewControllers/SWRevealViewController.h b/VoidLink/ViewControllers/SWRevealViewController.h index 18a7da67..52bc8f74 100755 --- a/VoidLink/ViewControllers/SWRevealViewController.h +++ b/VoidLink/ViewControllers/SWRevealViewController.h @@ -243,6 +243,9 @@ typedef NS_ENUM(NSInteger, SWRevealToggleAnimationType) // Thus, you can disable user interactions on your frontViewController view without affecting the tap recognizer. - (UITapGestureRecognizer*)tapGestureRecognizer; +- (void)favoriteSettingSelected; +- (void)allSettingSelected; + /* The following properties are provided for further customization, they are set to default values on initialization, you do not generally have to set them */ diff --git a/VoidLink/ViewControllers/SettingsViewController.h b/VoidLink/ViewControllers/SettingsViewController.h index b3deaef8..4954c876 100644 --- a/VoidLink/ViewControllers/SettingsViewController.h +++ b/VoidLink/ViewControllers/SettingsViewController.h @@ -10,21 +10,21 @@ // #import -#import "AppDelegate.h" -#import "MainFrameViewController.h" -#import "CustomEdgeSlideGestureRecognizer.h" -#import "VoidLink-Swift.h" +#import "SWRevealViewController.h" @class LayoutOnScreenControlsViewController; +@class MainFrameViewController; @class MicHandler; @class WidgetPickerViewController; -@protocol WidgetPickerViewControllerDelegate; -@interface SettingsViewController : UIViewController +@interface SettingsViewController : UIViewController @property (strong, nonatomic) IBOutlet UINavigationBar *navigationBar; @property (strong, nonatomic) UIStackView *parentStack; @property (strong, nonatomic) IBOutlet UIStackView *resolutionStack; +@property (strong, nonatomic) IBOutlet UIStackView *resolutionSelectorStack; +@property (weak, nonatomic) IBOutlet UIStackView *customResolutionStack; + @property (strong, nonatomic) IBOutlet UIStackView *fpsStack; @property (strong, nonatomic) IBOutlet UIStackView *bitrateStack; @property (strong, nonatomic) IBOutlet UIStackView *touchModeStack; @@ -103,7 +103,8 @@ @property (strong, nonatomic) IBOutlet UILabel *mousePointerVelocityFactorUILabel; @property (strong, nonatomic) IBOutlet UISegmentedControl *unlockDisplayOrientationSelector; @property (weak, nonatomic) LayoutOnScreenControlsViewController *layoutOnScreenControlsVC; -@property (nonatomic, strong) MainFrameViewController *mainFrameViewController; +@property (nonatomic, weak) MainFrameViewController *mainFrameViewController; +@property (strong, nonatomic) UIView *controllerNavigationHighlightOverlayView; @property (strong, nonatomic) IBOutlet UISegmentedControl *externalDisplayModeSelector; @property (strong, nonatomic) IBOutlet UISegmentedControl *localMousePointerModeSelector; @@ -231,8 +232,11 @@ @property (weak, nonatomic) IBOutlet UIStackView *softKeyboardHeightStack; @property (weak, nonatomic) IBOutlet UISwitch *softKeyboardHeightSwitch; -@property (strong, nonatomic) IBOutlet UIStackView *controllerToMouseStack; -@property (strong, nonatomic) IBOutlet UISwitch *controllerToMouseSwitch; +@property (strong, nonatomic) IBOutlet UIStackView *controllerNavigationStack; +@property (strong, nonatomic) IBOutlet UISwitch *controllerNavigationSwitch; + +@property (weak, nonatomic) IBOutlet UIStackView *streamingRadialMenuDelayStack; +@property (weak, nonatomic) IBOutlet UISlider *streamingRadialMenuDelaySlider; @property (strong, nonatomic) IBOutlet UIStackView *controllerMouseVelocityStack; @property (strong, nonatomic) IBOutlet UISlider *controllerMouseVelocitySlider; @@ -307,11 +311,13 @@ // This is okay because it's just an enum and access uses @available checks @property(nonatomic) UIUserInterfaceStyle overrideUserInterfaceStyle; +@property(nonatomic, readonly) SettingsMenuMode currentSettingsMenuMode; #pragma clang diagnostic pop - (bool)hdrSupported; - (void)saveSettings; +- (void)saveFavoriteSettingStackIdentifiers; + (bool)isLandscapeNow; - (void)updateResolutionTable; - (void)widget:(UIView*)widget setEnabled:(bool)enabled; @@ -320,5 +326,7 @@ - (void)setHidden:(BOOL)hidden forStack:(UIStackView* )stack; - (void)updateCodecDependentSwitches; - (void)mainFrameGameProfileButtonTapped:(bool)animated; +- (void)addSettingToFavorite:(UIStackView* )settingStack; +- (void)expandGamepadSection; @end diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index b71be0cd..b05554ef 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -10,6 +10,8 @@ // #import "SettingsViewController.h" +#import "MainFrameViewController.h" +#import "CustomEdgeSlideGestureRecognizer.h" #import "TemporarySettings.h" #import "DataManager.h" #import "VoidLink-Swift.h" @@ -20,6 +22,10 @@ #import "LocalizationHelper.h" +@interface SettingsViewController () +@property(nonatomic, assign) SettingsMenuMode currentSettingsMenuMode; +@end + @implementation SettingsViewController { TemporarySettings* tempSettings; DataManager* dataMan; @@ -41,7 +47,6 @@ NSMutableDictionary *_settingStackDict; NSMutableArray *_favoriteSettingStackIdentifiers; bool settingStackWillBeRelocatedToLowestPosition; - uint8_t currentSettingsMenuMode; UIView *snapshot; UIStackView* capturedStack; CADisplayLink *_autoScrollDisplayLink; @@ -54,11 +59,15 @@ MenuSectionView *otherSection; MenuSectionView *experimentalSection; NSMutableSet* hiddenStacks; + + // ControllerNavigator UI navigation state + UIView *_controllerNavigationHighlightOverlayView; GCController *capturedController; } @dynamic overrideUserInterfaceStyle; +@synthesize controllerNavigationHighlightOverlayView = _controllerNavigationHighlightOverlayView; //static NSString* bitrateFormat; @@ -373,7 +382,7 @@ BOOL isCustomResolution(int resolutionSelected) { self.controllerGyroSwitchButtonSetter.selectedSegmentIndex = oscProfile.controllerGyroSwitchMode; if(self.controllerGyroSwitchButtonSetter.selectedSegmentIndex != ControllerGyroSwitchDisabled){ - bool bothButtonsSet = oscProfile.controllerGyroSwitchHold != ControllerButtonNull && oscProfile.controllerGyroSwitchToggle != ControllerButtonNull; + bool bothButtonsSet = oscProfile.controllerGyroSwitchHold != ControllerElementNull && oscProfile.controllerGyroSwitchToggle != ControllerElementNull; [self findDynamicLabelFromStack:self.controllerGyroSwitchButtonStack].text = bothButtonsSet ? [LocalizationHelper localizedStringForKey:@" both set "] : @""; } [self setHidden:oscProfile.controllerGyroSwitchMode==ControllerGyroSwitchDisabled forStack:self.reverseHoldButtonStack]; @@ -556,7 +565,7 @@ BOOL isCustomResolution(int resolutionSelected) { else dispatch_async(dispatch_get_main_queue(), ^{[self widget:self.bitrateSlider setEnabled:true];}); }); - if(currentSettingsMenuMode == AllSettings && MenuSectionView.overridePersistedFoldState){ + if(self.currentSettingsMenuMode == AllSettings && MenuSectionView.overridePersistedFoldState){ for(UIView *subview in _parentStack.arrangedSubviews){ if([subview isKindOfClass:[MenuSectionView class]]){ MenuSectionView* section = (MenuSectionView* )subview; @@ -619,11 +628,16 @@ BOOL isCustomResolution(int resolutionSelected) { [self touchModeChanged:self.touchModeSelector1]; // a special fix for iOS 14 to set hidden for the "enableOswStack" - if(!settingsViewAlreadyAppeared && ![self contentOffsetRestored]) _scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x, tempSettings.settingsMenuOffset.floatValue); + if(!settingsViewAlreadyAppeared) { + if(![self contentOffsetRestored]) _scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x, tempSettings.settingsMenuOffset.floatValue); + if(@available(iOS 13.0, *)) if(ControllerUtil.primaryGCController) [self restoreControllerNavigationHighlight]; + } _scrollView.hidden = false; - + settingsViewJustExpanded = false; settingsViewAlreadyAppeared = true; + + if (@available(iOS 13.0, *)) if(ControllerNavigator.radialMenuView.superview) [ControllerNavigator updateRadialMenu]; } - (void)viewWillDisappear:(BOOL)animated{ @@ -637,11 +651,12 @@ BOOL isCustomResolution(int resolutionSelected) { bool unlockDisplayOrientationFlipped = tempSettings.unlockDisplayOrientation != (_unlockDisplayOrientationSelector.selectedSegmentIndex == 1); if(unlockDisplayOrientationFlipped) [_mainFrameViewController setNeedsUpdateAllowedOrientation]; // handle allow portratit on & off + if (@available(iOS 13.0, *)) if(ControllerNavigator.radialMenuView.superview) [ControllerNavigator updateRadialMenu]; } - (SettingsMenuMode)getSettingsMenuMode{ - return currentSettingsMenuMode; + return self.currentSettingsMenuMode; } - (void)edgeSwiped { @@ -666,7 +681,7 @@ BOOL isCustomResolution(int resolutionSelected) { if(!_parentStack.superview){ [self.scrollView addSubview:_parentStack]; [NSLayoutConstraint activateConstraints:@[ - [_parentStack.topAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.topAnchor constant: currentSettingsMenuMode == AllSettings ? GenericUtils.settingsMenuNavigationBarHeight : GenericUtils.settingsMenuNavigationBarHeight+10], + [_parentStack.topAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.topAnchor constant: self.currentSettingsMenuMode == AllSettings ? GenericUtils.settingsMenuNavigationBarHeight : GenericUtils.settingsMenuNavigationBarHeight+10], [_parentStack.bottomAnchor constraintEqualToAnchor:self.scrollView.contentLayoutGuide.bottomAnchor constant:-20], ]]; } @@ -841,7 +856,7 @@ BOOL isCustomResolution(int resolutionSelected) { [videoSection setSectionWithIcon:[UIImage systemImageNamed:@"waveform"] size:13 sizeConstraint:-15]; } - self.resolutionStack.hasDynamicLabel = YES; + [self addDynamicLabelForStack:self.resolutionSelectorStack]; [self addSetting:self.resolutionStack ofId:@"resolutionStack" to:videoSection]; [self addSetting:self.fpsStack ofId:@"fpsStack" to:videoSection]; @@ -933,6 +948,20 @@ BOOL isCustomResolution(int resolutionSelected) { if (@available(iOS 13.0, *)) { [controllerSection setSectionWithIcon:[UIImage systemImageNamed:@"gamecontroller"] size:30 sizeConstraint:-10]; } + + self.controllerNavigationStack.hasInfoTag = YES; + self.controllerNavigationStack.hasDynamicLabel = YES; + [self addSetting:self.controllerNavigationStack ofId:@"controllerNavigationStack" to:controllerSection]; + + self.streamingRadialMenuDelayStack.hasDynamicLabel = YES; + [self addSetting:self.streamingRadialMenuDelayStack ofId:@"streamingRadialMenuDelayStack" to:controllerSection]; + + self.controllerMouseVelocityStack.hasDynamicLabel = YES; + [self addSetting:self.controllerMouseVelocityStack ofId:@"controllerMouseVelocityStack" to:controllerSection]; + + self.controllerMouseExpoStack.hasInfoTag = YES; + self.controllerMouseExpoStack.hasDynamicLabel = YES; + [self addSetting:self.controllerMouseExpoStack ofId:@"controllerMouseExpoStack" to:controllerSection]; [self addSetting:self.swapAbxyStack ofId:@"swapAbaxyStack" to:controllerSection]; [self addSetting:self.hapticEngineStack ofId:@"hapticEngineStack" to:controllerSection]; @@ -960,17 +989,6 @@ BOOL isCustomResolution(int resolutionSelected) { self.rightStickMinOffsetStack.hasDynamicLabel = YES; [self addSetting:self.rightStickMinOffsetStack ofId:@"rightStickMinOffsetStack" to:controllerSection]; - self.controllerToMouseStack.hasInfoTag = YES; - self.controllerToMouseStack.hasDynamicLabel = YES; - [self addSetting:self.controllerToMouseStack ofId:@"controllerToMouseStack" to:controllerSection]; - - self.controllerMouseVelocityStack.hasDynamicLabel = YES; - [self addSetting:self.controllerMouseVelocityStack ofId:@"controllerMouseVelocityStack" to:controllerSection]; - - self.controllerMouseExpoStack.hasInfoTag = YES; - self.controllerMouseExpoStack.hasDynamicLabel = YES; - [self addSetting:self.controllerMouseExpoStack ofId:@"controllerMouseExpoStack" to:controllerSection]; - [controllerSection addToParentStack:_parentStack]; @@ -1203,7 +1221,6 @@ BOOL isCustomResolution(int resolutionSelected) { // self.touchMoveEventIntervalStack.hasDynamicLabel = YES; // [self addSetting:self.touchMoveEventIntervalStack ofId:@"touchMoveEventIntervalStack" to:experimentalSection]; - self.relativeTouchSlideThresholdStack.hasInfoTag = YES; self.relativeTouchSlideThresholdStack.hasDynamicLabel = YES; [self addSetting:self.relativeTouchSlideThresholdStack ofId:@"relativeTouchSlideThresholdStack" to:experimentalSection]; @@ -1228,6 +1245,11 @@ BOOL isCustomResolution(int resolutionSelected) { [experimentalSection addToParentStack:_parentStack]; } +- (void)expandGamepadSection { + if(_currentSettingsMenuMode == FavoriteSettings) [self switchToAllSettings]; + [controllerSection setExpanded:true]; +} + - (void)handleAutoScroll:(CGPoint)location{ bool scrollDown = location.y > self.view.bounds.size.height - 100; bool scrollUp = location.y < 150; @@ -1432,7 +1454,7 @@ BOOL isCustomResolution(int resolutionSelected) { - (void)handleLongPress:(UILongPressGestureRecognizer *)gesture { CGPoint locationInParentStack = [gesture locationInView:_parentStack]; CGPoint locationInRootView = [gesture locationInView:self.view.superview]; - if(currentSettingsMenuMode == AllSettings &&gesture.state == UIGestureRecognizerStateBegan) { + if(self.currentSettingsMenuMode == AllSettings &&gesture.state == UIGestureRecognizerStateBegan) { [self findCapturedStackByTouchLocation:locationInParentStack]; if(capturedStack == nil) return; [self highlightedBackgroundForView:capturedStack animateWithDuration:0 completion:nil]; @@ -1452,7 +1474,7 @@ BOOL isCustomResolution(int resolutionSelected) { _autoScrollDisplayLink = nil; } - if(currentSettingsMenuMode == FavoriteSettings){ + if(self.currentSettingsMenuMode == FavoriteSettings){ switch (gesture.state) { case UIGestureRecognizerStateBegan: // 创建快照视图 @@ -1517,7 +1539,9 @@ BOOL isCustomResolution(int resolutionSelected) { if([_favoriteSettingStackIdentifiers containsObject:settingStack.accessibilityIdentifier]) return; - [_favoriteSettingStackIdentifiers addObject:settingStack.accessibilityIdentifier]; + if(settingStack.accessibilityIdentifier) [_favoriteSettingStackIdentifiers addObject:settingStack.accessibilityIdentifier]; + else if(settingStack.superview.accessibilityIdentifier) [_favoriteSettingStackIdentifiers addObject:settingStack.superview.accessibilityIdentifier]; + for(NSString *identifier in _favoriteSettingStackIdentifiers){ NSLog(@"favorite setting: %@", identifier); } @@ -1590,6 +1614,7 @@ BOOL isCustomResolution(int resolutionSelected) { NSString* tipText = @""; NSString* onlineDocLink = @""; bool showOnlineDocAction = false; + tipText = @""; if([sender.superview.accessibilityIdentifier isEqualToString: @"bitrateStack"]){ @@ -1722,9 +1747,9 @@ BOOL isCustomResolution(int resolutionSelected) { tipText = [LocalizationHelper localizedStringForKey:@"controllerGyroSwitchButtonStackTip", [ControllerUtil stringFor:oscProfile.controllerGyroSwitchToggle], [ControllerUtil stringFor:oscProfile.controllerGyroSwitchHold]]; showOnlineDocAction = false; } - if([sender.superview.accessibilityIdentifier isEqualToString: @"controllerToMouseStack"]){ + if([sender.superview.accessibilityIdentifier isEqualToString: @"controllerNavigationStack"]){ tempSettings = [dataMan getSettings]; - tipText = [LocalizationHelper localizedStringForKey:@"controllerToMouseStackTip", [ControllerUtil stringFor:tempSettings.controllerMouseSwitch.intValue], [LocalizationHelper localizedStringForKey:tempSettings.controllerMouseStick.intValue == LeftStickToMouse ? @"Left stick" : @"Right stick"], [ControllerUtil stringFor:tempSettings.controllerMouseLeftButton.intValue], [ControllerUtil stringFor:tempSettings.controllerMouseRightButton.intValue]]; + tipText = [LocalizationHelper localizedStringForKey:@"controllerNavigationStackTip", [ControllerUtil stringFor:tempSettings.localRadialMenuButton.intValue], [ControllerUtil stringFor:tempSettings.streamingRadialMenuButton.intValue], [ControllerUtil stringFor:tempSettings.controllerMouseStick.intValue], [ControllerUtil stringFor:tempSettings.controllerMouseLeftButton.intValue], [ControllerUtil stringFor:tempSettings.controllerMouseRightButton.intValue]]; showOnlineDocAction = false; } if([sender.superview.accessibilityIdentifier isEqualToString: @"reverseHoldButtonStack"]){ @@ -1846,7 +1871,7 @@ BOOL isCustomResolution(int resolutionSelected) { //switchToAll/Favorite 调用此方法时,这些hiddenStack已身处新的superView中, 可以正常执行hidden = YES for(UIStackView* stack in hiddenStacks) stack.hidden = YES; - if(currentSettingsMenuMode == AllSettings){ + if(self.currentSettingsMenuMode == AllSettings){ for(MenuSectionView* section in _parentStack.arrangedSubviews) [section updateViewForFoldState]; } [self hideDynamicLabelsWhenOverlapped:self.parentStack]; @@ -1857,7 +1882,7 @@ BOOL isCustomResolution(int resolutionSelected) { for(UIStackView* stack in hiddenStacks) { stack.hidden = NO; } - if(currentSettingsMenuMode == AllSettings){ + if(self.currentSettingsMenuMode == AllSettings){ for(UIView* view in _parentStack.arrangedSubviews){ if([view isKindOfClass:[MenuSectionView class]]){ MenuSectionView* section = (MenuSectionView* )view; @@ -1876,20 +1901,16 @@ BOOL isCustomResolution(int resolutionSelected) { - (void)switchToFavoriteSettings{ [self forceRestoreHeightTemporarilyForSettingStackParentView]; [_parentStack removeFromSuperview]; - currentSettingsMenuMode = FavoriteSettings; + self.currentSettingsMenuMode = FavoriteSettings; [self initParentStack]; [self updateTheme]; Settings *currentSettings = [dataMan retrieveSettings]; - currentSettings.settingsMenuMode = [NSNumber numberWithInteger:currentSettingsMenuMode]; + currentSettings.settingsMenuMode = [NSNumber numberWithInteger:self.currentSettingsMenuMode]; [dataMan saveData]; _parentStack.spacing = [self isIPhone] ? 10 : 12; [self loadFavoriteSettingStackIdentifiers]; - for(NSString* settingIdentifier in _favoriteSettingStackIdentifiers){ - [_parentStack addArrangedSubview:_settingStackDict[settingIdentifier]]; - } - for(NSString* settingIdentifier in _favoriteSettingStackIdentifiers){ [_parentStack addArrangedSubview:_settingStackDict[settingIdentifier]]; } @@ -1903,11 +1924,20 @@ BOOL isCustomResolution(int resolutionSelected) { [self hideDynamicLabelsWhenOverlapped:self.parentStack]; [self layoutSettingsView]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (@available(iOS 13.0, *)) { + if(ControllerNavigator.radialMenuView.superview) [ControllerNavigator updateRadialMenu]; + [GamepadNavigationIllustrationHud updateHud]; + if(ControllerUtil.primaryGCController){ + [ControllerNavigator restoreSettingsModeSwitchHighlight]; + }; + } + }); } - (void)switchToAllSettings{ [self forceRestoreHeightTemporarilyForSettingStackParentView]; - currentSettingsMenuMode = AllSettings; + self.currentSettingsMenuMode = AllSettings; [_parentStack removeFromSuperview]; [self initParentStack]; [self layoutSections]; @@ -1915,13 +1945,22 @@ BOOL isCustomResolution(int resolutionSelected) { [self updateTheme]; //[self doneRemoveSettingItem]; Settings *currentSettings = [dataMan retrieveSettings]; - currentSettings.settingsMenuMode = [NSNumber numberWithInteger:currentSettingsMenuMode]; + currentSettings.settingsMenuMode = [NSNumber numberWithInteger:self.currentSettingsMenuMode]; [dataMan saveData]; [self layoutSettingsView]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (@available(iOS 13.0, *)){ + if(ControllerNavigator.radialMenuView.superview) [ControllerNavigator updateRadialMenu]; + [GamepadNavigationIllustrationHud updateHud]; + if(ControllerUtil.primaryGCController){ + [ControllerNavigator restoreSettingsModeSwitchHighlight]; + }; + } + }); } - (void)enterRemoveSettingItemMode{ - currentSettingsMenuMode = RemoveSettingItem; + self.currentSettingsMenuMode = RemoveSettingItem; for(UIStackView* stack in _parentStack.arrangedSubviews){ for(UIView* view in stack.subviews){ if([view.accessibilityIdentifier isEqualToString:@"infoButton"]) view.hidden = YES; @@ -1933,7 +1972,7 @@ BOOL isCustomResolution(int resolutionSelected) { } - (void)doneRemoveSettingItem{ - currentSettingsMenuMode = FavoriteSettings; + self.currentSettingsMenuMode = FavoriteSettings; for(UIStackView* stack in _parentStack.arrangedSubviews){ //stack.userInteractionEnabled = true; for(UIView* view in stack.subviews){ @@ -1960,7 +1999,7 @@ BOOL isCustomResolution(int resolutionSelected) { - (void)saveFavoriteSettingStackIdentifiers { - if(currentSettingsMenuMode != AllSettings){ + if(self.currentSettingsMenuMode != AllSettings){ [_favoriteSettingStackIdentifiers removeAllObjects]; //for(NSInteger i = 0; i < parentStack.arrangedSubviews.count; i++){ for(NSInteger i = 0; i < _parentStack.arrangedSubviews.count; i++){ @@ -2061,7 +2100,7 @@ BOOL isCustomResolution(int resolutionSelected) { } - self->currentSettingsMenuMode = self->tempSettings.settingsMenuMode.intValue; + self.currentSettingsMenuMode = self->tempSettings.settingsMenuMode.intValue; [self loadFavoriteSettingStackIdentifiers]; if(self->tempSettings.settingsMenuMode.intValue == FavoriteSettings) [self switchToFavoriteSettings]; @@ -2074,6 +2113,9 @@ BOOL isCustomResolution(int resolutionSelected) { // 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.resolutionSelectorStack.accessibilityIdentifier = @"resolutionSelectorStack"; + self.customResolutionStack.accessibilityIdentifier = @"customResolutionStack"; [self.resolutionSelector removeSegmentAtIndex:0 animated:NO]; // remove 360p [self.resolutionSelector removeSegmentAtIndex:5 animated:NO]; // remove custom segment @@ -2418,15 +2460,15 @@ BOOL isCustomResolution(int resolutionSelected) { [self.onScreenWidgetSelector sendActionsForControlEvents:UIControlEventValueChanged]; /* + // touch move event interval for native-touch. [self.touchMoveEventIntervalSlider setValue:self->tempSettings.touchMoveEventInterval.intValue animated:NO]; // Load old setting. [self.touchMoveEventIntervalSlider addTarget:self action:@selector(touchMoveEventIntervalSliderMoved:) forControlEvents:(UIControlEventValueChanged)]; // Update label display when slider is being moved. [self touchMoveEventIntervalSliderMoved:self.touchMoveEventIntervalSlider]; + */ - // touch move event interval for native-touch. [self.leftClickDelaySlider setValue:self->tempSettings.leftClickDelayMs.intValue animated:NO]; // Load old setting. [self.leftClickDelaySlider addTarget:self action:@selector(leftClickDelaySliderMoved:) forControlEvents:(UIControlEventValueChanged)]; // Update label display when slider is being moved. - [self leftClickDelaySliderMoved:self.leftClickDelaySlider]; - */ + [self.leftClickDelaySlider sendActionsForControlEvents:UIControlEventValueChanged]; // touchMode refactored to game profile system [self.touchModeSelector1 addTarget:self action:@selector(touchMode1Changed:) forControlEvents:UIControlEventValueChanged]; @@ -2437,12 +2479,16 @@ BOOL isCustomResolution(int resolutionSelected) { [self.sendDummyEventSwitch setOn:self->tempSettings.sendDummyEvent];// Load old setting - [self.controllerToMouseSwitch setOn:self->tempSettings.mapControllerToMouse]; - [self.controllerToMouseSwitch addTarget:self action:@selector(controllerToMouseSwitchFlipped:) forControlEvents:UIControlEventValueChanged]; - [self.controllerToMouseSwitch sendActionsForControlEvents:UIControlEventValueChanged]; + [self.controllerNavigationSwitch setOn:self->tempSettings.enableControllerNavigation]; + [self.controllerNavigationSwitch addTarget:self action:@selector(controllerNavigationSwitchFlipped:) forControlEvents:UIControlEventValueChanged]; + [self.controllerNavigationSwitch sendActionsForControlEvents:UIControlEventValueChanged]; [self.controllerGyroSwitchButtonSetter addTarget:self action:@selector(controllerGyroSwitchModeChanged:) forControlEvents:UIControlEventValueChanged]; + [self.streamingRadialMenuDelaySlider setValue:self->tempSettings.streamingRadialMenuDelay.floatValue]; + [self.streamingRadialMenuDelaySlider addTarget:self action:@selector(streamingRadialMenuDelaySliderMoved:) forControlEvents:UIControlEventValueChanged]; + [self.streamingRadialMenuDelaySlider sendActionsForControlEvents:UIControlEventValueChanged]; + [self.controllerMouseVelocitySlider setValue:self->tempSettings.controllerMousePointerVelocity.floatValue]; [self.controllerMouseVelocitySlider addTarget:self action:@selector(controllerMouseVelocitySliderMoved:) forControlEvents:UIControlEventValueChanged]; [self.controllerMouseVelocitySlider sendActionsForControlEvents:UIControlEventValueChanged]; @@ -2511,10 +2557,13 @@ BOOL isCustomResolution(int resolutionSelected) { NSLog(@"OK button tapped with no change"); } */ - // Continue execution after the alert is dismissed - if (!self->_mainFrameViewController.settingsExpandedInStreamView) { - [self invokeOscLayout]; // Don't open osc layout tool immediately during streaming - } + [alertController dismissViewControllerAnimated:true completion:^{ + // Continue execution after the alert is dismissed + if (!self->_mainFrameViewController.settingsExpandedInStreamView) { + [self invokeOscLayout]; // Don't open osc layout tool immediately during streaming + } + }]; + /* [self findDynamicLabelFromStack:self.onScreenWidgetStack].text = [self isCustomOswEnabled] ? [LocalizationHelper localizedStringForKey:@"%d finger tap", self->oswLayoutFingers] : @""; [self handleOswGestureChange]; */ @@ -2725,16 +2774,18 @@ BOOL isCustomResolution(int resolutionSelected) { } [self->dataMan saveData]; }]; - [AlertControllerUtil showAlertIn:self - title:[LocalizationHelper localizedStringForKey:@"Drift Correction"] - message:[LocalizationHelper localizedStringForKey:@"Calibrating..."] - withCancel:NO - buttonTitle:[LocalizationHelper localizedStringForKey:@"Finished!"] - countdown:6 - action:^{} - completion:^{ - self.mapGyroToSelector.selectedSegmentIndex = self->oscProfile.mapGyroTo; - [self.mapGyroToSelector sendActionsForControlEvents:UIControlEventValueChanged]; + [AlertControllerUtil.alertController dismissViewControllerAnimated:true completion:^{ + [AlertControllerUtil showAlertIn:self + title:[LocalizationHelper localizedStringForKey:@"Drift Correction"] + message:[LocalizationHelper localizedStringForKey:@"Calibrating..."] + withCancel:NO + buttonTitle:[LocalizationHelper localizedStringForKey:@"Finished!"] + countdown:6 + action:^{} + completion:^{ + self.mapGyroToSelector.selectedSegmentIndex = self->oscProfile.mapGyroTo; + [self.mapGyroToSelector sendActionsForControlEvents:UIControlEventValueChanged]; + }]; }]; } }]; @@ -2766,20 +2817,32 @@ BOOL isCustomResolution(int resolutionSelected) { if(self->capturedController){ __weak typeof(self) weakSelf = self; - [ControllerUtil listenWithController:self->capturedController swapABXY:false handler:^(NSDictionary * buttonDict, GCExtendedGamepad * gamepad, GCControllerElement * element) { + [ControllerUtil listenWithController:self->capturedController swapABXY:false handler:^(NSDictionary * elementDict, GCExtendedGamepad * gamepad, GCControllerElement * element) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; - for(NSNumber* buttonFlagId in buttonDict){ - GCControllerButtonInput * button = (GCControllerButtonInput *)buttonDict[buttonFlagId]; + for(NSNumber* elementEnumInstance in elementDict){ + GCControllerElement * element = (GCControllerButtonInput *)elementDict[elementEnumInstance]; + if (![element isKindOfClass:[GCControllerButtonInput class]]) continue; + + GCControllerButtonInput * button = (GCControllerButtonInput *)element; if(button.isPressed){ - if(sender.selectedSegmentIndex == ControllerGyroSwitchPressToToggle) self->oscProfile.controllerGyroSwitchToggle = buttonFlagId.intValue; - if(sender.selectedSegmentIndex == ControllerGyroSwitchHoldDown) self->oscProfile.controllerGyroSwitchHold = buttonFlagId.intValue; + if(sender.selectedSegmentIndex == ControllerGyroSwitchPressToToggle) self->oscProfile.controllerGyroSwitchToggle = elementEnumInstance.intValue; + if(sender.selectedSegmentIndex == ControllerGyroSwitchHoldDown) self->oscProfile.controllerGyroSwitchHold = elementEnumInstance.intValue; switchButtonCaptured = true; + AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Finished"]; + for(UIAlertAction* action in AlertControllerUtil.alertController.actions) { + action.enabled = false; + } gamepad.valueChangedHandler = nil; if(confirmAction) [confirmAction setValue:[LocalizationHelper localizedStringForKey:@"OK"] forKey:@"title"]; [self->oscProfileMan replaceSelectedProfileWith:self->oscProfile overwriteDefault:true]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1*NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + [AlertControllerUtil.alertController dismissViewControllerAnimated:NO completion:nil]; + if(AlertControllerUtil.completion) AlertControllerUtil.completion(); + }); } } }]; @@ -2807,14 +2870,14 @@ BOOL isCustomResolution(int resolutionSelected) { } completion:^{ if(!switchButtonCaptured){ - if(sender.selectedSegmentIndex == ControllerGyroSwitchPressToToggle) self->oscProfile.controllerGyroSwitchToggle = ControllerButtonNull; - if(sender.selectedSegmentIndex == ControllerGyroSwitchHoldDown) self->oscProfile.controllerGyroSwitchHold = ControllerButtonNull; + if(sender.selectedSegmentIndex == ControllerGyroSwitchPressToToggle) self->oscProfile.controllerGyroSwitchToggle = ControllerElementNull; + if(sender.selectedSegmentIndex == ControllerGyroSwitchHoldDown) self->oscProfile.controllerGyroSwitchHold = ControllerElementNull; [self->oscProfileMan replaceSelectedProfileWith:self->oscProfile overwriteDefault:true]; } - bool noSwitchButtonSet = self->oscProfile.controllerGyroSwitchHold == ControllerButtonNull && self->oscProfile.controllerGyroSwitchToggle == ControllerButtonNull; - bool duplicatedButtons = self->oscProfile.controllerGyroSwitchHold == self->oscProfile.controllerGyroSwitchToggle && self->oscProfile.controllerGyroSwitchToggle != ControllerButtonNull; - bool bothButtonsSet = self->oscProfile.controllerGyroSwitchHold != ControllerButtonNull && self->oscProfile.controllerGyroSwitchToggle != ControllerButtonNull && !duplicatedButtons; + bool noSwitchButtonSet = self->oscProfile.controllerGyroSwitchHold == ControllerElementNull && self->oscProfile.controllerGyroSwitchToggle == ControllerElementNull; + bool duplicatedButtons = self->oscProfile.controllerGyroSwitchHold == self->oscProfile.controllerGyroSwitchToggle && self->oscProfile.controllerGyroSwitchToggle != ControllerElementNull; + bool bothButtonsSet = self->oscProfile.controllerGyroSwitchHold != ControllerElementNull && self->oscProfile.controllerGyroSwitchToggle != ControllerElementNull && !duplicatedButtons; tipLabel.text = @""; if(bothButtonsSet) tipLabel.text = [LocalizationHelper localizedStringForKey:@" both set "]; if(duplicatedButtons){ @@ -2824,7 +2887,7 @@ BOOL isCustomResolution(int resolutionSelected) { } if(noSwitchButtonSet) sender.selectedSegmentIndex = ControllerGyroSwitchDisabled; if(!noSwitchButtonSet && !bothButtonsSet && !duplicatedButtons){ - sender.selectedSegmentIndex = self->oscProfile.controllerGyroSwitchToggle != ControllerButtonNull ? ControllerGyroSwitchPressToToggle : ControllerGyroSwitchHoldDown; + sender.selectedSegmentIndex = self->oscProfile.controllerGyroSwitchToggle != ControllerElementNull ? ControllerGyroSwitchPressToToggle : ControllerGyroSwitchHoldDown; } [self setHidden:sender.selectedSegmentIndex==ControllerGyroSwitchDisabled forStack:self.reverseHoldButtonStack]; @@ -2834,21 +2897,26 @@ BOOL isCustomResolution(int resolutionSelected) { } } -- (void)controllerToMouseSwitchFlipped:(UISwitch* )sender{ +- (void)controllerNavigationSwitchFlipped:(UISwitch* )sender{ + + [self setHidden:!sender.isOn forStack:_streamingRadialMenuDelayStack]; [self setHidden:!sender.isOn forStack:_controllerMouseVelocityStack]; [self setHidden:!sender.isOn forStack:_controllerMouseExpoStack]; if(!sender.isOn || settingsViewJustLoaded) return; [self.swapAbxySwitch setOn:false]; // swapAbxy can be enabled after mouse to controller is set - __block bool switchButtonCaptured = false; + __block bool localRadialMenuButtonCaptured = false; + __block bool streamingRadialMenuButtonCaptured = false; + __block bool previousTriggerPressed = false; __block bool stickCaptured = false; __block bool leftButtonCaptured = false; __block bool rightButtonCaptured = false; + __block bool metRadialButtonWithUndefinedPosition = false; __block NSMutableSet *capturedButtons = [NSMutableSet new]; Settings* currentSettings = [dataMan retrieveSettings]; [AlertControllerUtil showAlertIn:self - title:[LocalizationHelper localizedStringForKey:@"Map controller to mouse"] - message:[LocalizationHelper localizedStringForKey:@"mouseModeSwitchButtonTip"] - withCancel:YES + title:[LocalizationHelper localizedStringForKey:@"Controller Navigation"] + message:[LocalizationHelper localizedStringForKey:@"radialMenuButtonTip"] + withCancel:NO buttonTitle:@"" countdown:0 action:^{ @@ -2861,42 +2929,152 @@ BOOL isCustomResolution(int resolutionSelected) { element.preferredSystemGestureState = GCSystemGestureStateDisabled; } } + + [ControllerUtil stopListeningPrimaryControllerWithStopListenToRadialMenuButton:true]; - [ControllerUtil listenWithController:self->capturedController swapABXY:false handler:^(NSDictionary * buttonDict, GCExtendedGamepad * gamepad, GCControllerElement * element) { + [ControllerUtil listenWithController:self->capturedController swapABXY:false handler:^(NSDictionary * elementDict, GCExtendedGamepad * gamepad, GCControllerElement * element) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; - if(!switchButtonCaptured){ - for(NSNumber* buttonFlagId in buttonDict){ - GCControllerButtonInput * button = (GCControllerButtonInput *)buttonDict[buttonFlagId]; - if(button.isPressed && ![capturedButtons containsObject:buttonFlagId]){ - currentSettings.controllerMouseSwitch = buttonFlagId; - [capturedButtons addObject:buttonFlagId]; - switchButtonCaptured = true; - AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Move the stick you wish to use for mouse control. Another stick will be used for vertical & horizontal scroll."]; + if(!localRadialMenuButtonCaptured){ + for(NSNumber* elementEnumInstance in elementDict){ + if (@available(iOS 13.0, *)) if(![ControllerNavigator.radialMenuButtonPool containsObject:elementEnumInstance]) continue; + GCControllerElement * element = (GCControllerButtonInput *)elementDict[elementEnumInstance]; + if (![element isKindOfClass:[GCControllerButtonInput class]]) continue; + GCControllerButtonInput * button = (GCControllerButtonInput *)element; + if(button.isPressed){ + currentSettings.localRadialMenuButton = elementEnumInstance; + [capturedButtons addObject:elementEnumInstance]; + ControllerElementPosition position = [ControllerUtil positionFor:(ControllerElement)currentSettings.localRadialMenuButton.intValue]; + metRadialButtonWithUndefinedPosition = position == ControllerElementPositionUndefined || position == ControllerElementPositionMiddle; + localRadialMenuButtonCaptured = true; + if(button == gamepad.leftTrigger || button == gamepad.rightTrigger) previousTriggerPressed = true; + if(metRadialButtonWithUndefinedPosition) { + AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Which side of the controller is this button on?"]; + if (@available(iOS 13.0, *)) [ControllerNavigator updateHudForCustomRadialMenuButtonPosition]; + return; + } + AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Press the same or a different button for controller navigation during streaming."]; + + return; + } + else if(button == gamepad.leftTrigger || button == gamepad.rightTrigger) previousTriggerPressed = false; + } + } + + if(metRadialButtonWithUndefinedPosition && localRadialMenuButtonCaptured && !streamingRadialMenuButtonCaptured){ + for(NSNumber* elementEnumInstance in elementDict){ + GCControllerElement * element = (GCControllerButtonInput *)elementDict[elementEnumInstance]; + if (![element isKindOfClass:[GCControllerButtonInput class]]) continue; + GCControllerButtonInput * button = (GCControllerButtonInput *)element; + if(button.isPressed){ + switch (elementEnumInstance.intValue) { + case ControllerElementDpadLeft: + currentSettings.customLocalRadialMenuButtonPosition = @(ControllerElementPositionLeft); + metRadialButtonWithUndefinedPosition = false; + break; + case ControllerElementDpadRight: + currentSettings.customLocalRadialMenuButtonPosition = @(ControllerElementPositionRight); + metRadialButtonWithUndefinedPosition = false; + break; + case ControllerElementDpadUp: + currentSettings.customLocalRadialMenuButtonPosition = @(ControllerElementPositionMiddle); + metRadialButtonWithUndefinedPosition = false; + break; + default: + break; + } + + if(!metRadialButtonWithUndefinedPosition){ + if (@available(iOS 13.0, *)) [GamepadNavigationIllustrationHud clearHud]; + AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Press the same or a different button for controller navigation during streaming."]; + return; + } + } + } + } + + if(!streamingRadialMenuButtonCaptured && localRadialMenuButtonCaptured && !metRadialButtonWithUndefinedPosition){ + for(NSNumber* elementEnumInstance in elementDict){ + if (@available(iOS 13.0, *)) if(![ControllerNavigator.radialMenuButtonPool containsObject:elementEnumInstance]) continue; + GCControllerElement * element = (GCControllerButtonInput *)elementDict[elementEnumInstance]; + if (![element isKindOfClass:[GCControllerButtonInput class]]) continue; + GCControllerButtonInput * button = (GCControllerButtonInput *)element; + if(button == gamepad.leftTrigger || button == gamepad.rightTrigger) { + if(button.isPressed == previousTriggerPressed) continue; + } + if(button.isPressed){ + currentSettings.streamingRadialMenuButton = elementEnumInstance; + [capturedButtons addObject:elementEnumInstance]; + streamingRadialMenuButtonCaptured = true; + ControllerElementPosition position = [ControllerUtil positionFor:(ControllerElement)currentSettings.streamingRadialMenuButton.intValue]; + metRadialButtonWithUndefinedPosition = currentSettings.streamingRadialMenuButton.intValue != currentSettings.localRadialMenuButton.intValue && (position == ControllerElementPositionUndefined || position == ControllerElementPositionMiddle); + if(currentSettings.streamingRadialMenuButton.intValue == currentSettings.localRadialMenuButton.intValue) currentSettings.customStreamingRadialMenuButtonPosition = currentSettings.customLocalRadialMenuButtonPosition; + if(metRadialButtonWithUndefinedPosition) { + AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Which side of the controller is this button on?"]; + if (@available(iOS 13.0, *)) [ControllerNavigator updateHudForCustomRadialMenuButtonPosition]; + return; + } + AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Move the stick you want to use for mouse control. The other stick will be used for vertical and horizontal scrolling"]; return; } } } - if(!stickCaptured && switchButtonCaptured){ + if(metRadialButtonWithUndefinedPosition && streamingRadialMenuButtonCaptured && localRadialMenuButtonCaptured){ + for(NSNumber* elementEnumInstance in elementDict){ + GCControllerElement * element = (GCControllerButtonInput *)elementDict[elementEnumInstance]; + if (![element isKindOfClass:[GCControllerButtonInput class]]) continue; + GCControllerButtonInput * button = (GCControllerButtonInput *)element; + if(button.isPressed){ + switch (elementEnumInstance.intValue) { + case ControllerElementDpadLeft: + currentSettings.customStreamingRadialMenuButtonPosition = @(ControllerElementPositionLeft); + metRadialButtonWithUndefinedPosition = false; + break; + case ControllerElementDpadRight: + currentSettings.customStreamingRadialMenuButtonPosition = @(ControllerElementPositionRight); + metRadialButtonWithUndefinedPosition = false; + break; + case ControllerElementDpadUp: + currentSettings.customStreamingRadialMenuButtonPosition = @(ControllerElementPositionMiddle); + metRadialButtonWithUndefinedPosition = false; + break; + default: + break; + } + if(!metRadialButtonWithUndefinedPosition){ + if (@available(iOS 13.0, *)) [GamepadNavigationIllustrationHud clearHud]; + AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Move the stick you want to use for mouse control. The other stick will be used for vertical and horizontal scrolling"]; + return; + } + } + } + } + + + if(!stickCaptured && streamingRadialMenuButtonCaptured && !metRadialButtonWithUndefinedPosition){ float leftStickOffset = hypotf(gamepad.leftThumbstick.xAxis.value, gamepad.leftThumbstick.yAxis.value); float rightStickOffset = hypotf(gamepad.rightThumbstick.xAxis.value, gamepad.rightThumbstick.yAxis.value); if(leftStickOffset>0.1||rightStickOffset>0.1){ - ControllerMouseStick stick = leftStickOffset>rightStickOffset ? LeftStickToMouse : RightStickToMouse; + ControllerElement stick = leftStickOffset>rightStickOffset ? ControllerElementLeftStick : ControllerElementRightStick; currentSettings.controllerMouseStick = @(stick); stickCaptured = true; AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Press the button for mouse left button"]; + return; } - return; } + + if(!leftButtonCaptured && stickCaptured){ - for(NSNumber* buttonFlagId in buttonDict){ - GCControllerButtonInput * button = (GCControllerButtonInput *)buttonDict[buttonFlagId]; - if(button.isPressed && ![capturedButtons containsObject:buttonFlagId]){ - currentSettings.controllerMouseLeftButton = buttonFlagId; - [capturedButtons addObject:buttonFlagId]; + for(NSNumber* elementEnumInstance in elementDict){ + GCControllerElement * element = (GCControllerButtonInput *)elementDict[elementEnumInstance]; + if (![element isKindOfClass:[GCControllerButtonInput class]]) continue; + GCControllerButtonInput * button = (GCControllerButtonInput *)elementDict[elementEnumInstance]; + if(button.isPressed && ![capturedButtons containsObject:elementEnumInstance]){ + currentSettings.controllerMouseLeftButton = elementEnumInstance; + [capturedButtons addObject:elementEnumInstance]; leftButtonCaptured = true; AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Press the button for mouse right button"]; return; @@ -2905,17 +3083,42 @@ BOOL isCustomResolution(int resolutionSelected) { } if(!rightButtonCaptured && leftButtonCaptured){ - for(NSNumber* buttonFlagId in buttonDict){ - GCControllerButtonInput * button = (GCControllerButtonInput *)buttonDict[buttonFlagId]; - if(button.isPressed && ![capturedButtons containsObject:buttonFlagId]){ - currentSettings.controllerMouseRightButton = buttonFlagId; - [capturedButtons addObject:buttonFlagId]; + for(NSNumber* elementEnumInstance in elementDict){ + GCControllerElement * element = (GCControllerButtonInput *)elementDict[elementEnumInstance]; + if (![element isKindOfClass:[GCControllerButtonInput class]]) continue; + GCControllerButtonInput * button = (GCControllerButtonInput *)elementDict[elementEnumInstance]; + if(button.isPressed && ![capturedButtons containsObject:elementEnumInstance]){ + currentSettings.controllerMouseRightButton = elementEnumInstance; + [capturedButtons addObject:elementEnumInstance]; rightButtonCaptured = true; + + if (@available(iOS 13.0, *)) { + NSLog(@"custom posistion local %d , stream: %d", currentSettings.customLocalRadialMenuButtonPosition.intValue, currentSettings.customStreamingRadialMenuButtonPosition.intValue); + + ControllerNavigator.localRadialMenuButton = (ControllerElement)currentSettings.localRadialMenuButton.intValue; + ControllerNavigator.customPositionForLocalRadialMenuButton = (ControllerElementPosition)currentSettings.customLocalRadialMenuButtonPosition.intValue; + ControllerNavigator.streamingRadialMenuButton = (ControllerElement)currentSettings.streamingRadialMenuButton.intValue; + ControllerNavigator.customPositionForStreamingRadialMenuButton = (ControllerElementPosition)currentSettings.customStreamingRadialMenuButtonPosition.intValue; + ControllerNavigator.streamingRadialMenuDelay = (NSTimeInterval)currentSettings.streamingRadialMenuDelay.floatValue; + ControllerNavigator.enabled = true; + [ControllerNavigator setUINavigationDelegate:self]; + // ControllerNavigator.controllerMouseStick = (ControllerElement)currentSettings.controllerMouseStick.intValue; + // ControllerNavigator.controllerMouseLeftButton = (ControllerElement)currentSettings.controllerMouseLeftButton.intValue; + // ControllerNavigator.controllerMouseRightButton = (ControllerElement)currentSettings.controllerMouseRightButton.intValue; + // ControllerNavigator.controllerMouseExpo = currentSettings.controllerMouseExpo.floatValue; + } + AlertControllerUtil.alertController.message = [LocalizationHelper localizedStringForKey:@"Finished"]; gamepad.valueChangedHandler = nil; + // deprecated: UIAlertAction* cancelAction = AlertControllerUtil.alertController.actions.firstObject; if(cancelAction) [cancelAction setValue:[LocalizationHelper localizedStringForKey:@"OK"] forKey:@"title"]; + // [self->dataMan saveData]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1*NSEC_PER_SEC), dispatch_get_main_queue(), ^{ + if (@available(iOS 13.0, *)) [ControllerNavigator restartListening]; + [AlertControllerUtil.alertController dismissViewControllerAnimated:NO completion:nil]; + }); } } } @@ -2932,12 +3135,12 @@ BOOL isCustomResolution(int resolutionSelected) { action:^{} completion:^{ if(AlertControllerUtil.actionCancelled){ - [self.controllerToMouseSwitch setOn:NO]; - [self.controllerToMouseSwitch sendActionsForControlEvents:UIControlEventValueChanged]; + [self.controllerNavigationSwitch setOn:NO]; + [self.controllerNavigationSwitch sendActionsForControlEvents:UIControlEventValueChanged]; return; } [AlertControllerUtil.alertController dismissViewControllerAnimated:NO completion:^{}]; - [self.controllerToMouseSwitch sendActionsForControlEvents:UIControlEventValueChanged]; + [self.controllerNavigationSwitch sendActionsForControlEvents:UIControlEventValueChanged]; }]; }]; } @@ -2945,8 +3148,8 @@ BOOL isCustomResolution(int resolutionSelected) { completion:^{ if(AlertControllerUtil.actionCancelled){ if(!rightButtonCaptured){ - [self.controllerToMouseSwitch setOn:rightButtonCaptured]; - [self.controllerToMouseSwitch sendActionsForControlEvents:UIControlEventValueChanged]; + [self.controllerNavigationSwitch setOn:rightButtonCaptured]; + [self.controllerNavigationSwitch sendActionsForControlEvents:UIControlEventValueChanged]; } if(self->capturedController && self->capturedController.extendedGamepad) self->capturedController.extendedGamepad.valueChangedHandler = nil; } @@ -2972,25 +3175,25 @@ BOOL isCustomResolution(int resolutionSelected) { } - (void)rollSensitivitySliderMoved:(UISlider* )sender{ - [self findDynamicLabelFromStack:_rollSensitivityStack].text = [NSString stringWithFormat:@" %d%% ", (int16_t)[self map_velocFactorDisplay_fromSliderValue:sender.value]]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (int16_t)[self map_velocFactorDisplay_fromSliderValue:sender.value]]; } - (void)gyroMinStickOffsetSliderMoved:(UISlider* )sender{ - [self findDynamicLabelFromStack:_gyroToStickMinOffsetStack].text = [NSString stringWithFormat:@" %d ", (int16_t)sender.value]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d ", (int16_t)sender.value]; if(settingsViewJustExpanded) return; LiSendControllerEvent(0, 0, 0, _rollToLeftStickSwitch.isOn?sender.value:0, 0, _yawPitchToRightStickSwitch.isOn?sender.value:0, 0); OnScreenWidgetView.gamepadArrivalReported = true; } - (void)leftStickMinOffsetSliderMoved:(UISlider* )sender{ - [self findDynamicLabelFromStack:_leftStickMinOffsetStack].text = [NSString stringWithFormat:@" %d ", (int16_t)sender.value]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d ", (int16_t)sender.value]; if(settingsViewJustExpanded) return; LiSendControllerEvent(0, 0, 0, sender.value, 0, 0, 0); OnScreenWidgetView.gamepadArrivalReported = true; } - (void)rightStickMinOffsetSliderMoved:(UISlider* )sender{ - [self findDynamicLabelFromStack:_rightStickMinOffsetStack].text = [NSString stringWithFormat:@" %d ", (int16_t)sender.value]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d ", (int16_t)sender.value]; if(settingsViewJustExpanded) return; LiSendControllerEvent(0, 0, 0, 0, 0, sender.value, 0); OnScreenWidgetView.gamepadArrivalReported = true; @@ -3056,24 +3259,24 @@ BOOL isCustomResolution(int resolutionSelected) { } - (void) pointerVelocityModeDividerSliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:self.pointerVelocityDividerStack].text = [NSString stringWithFormat:@" | %d%% | %d%% | ", (uint8_t)sender.value, 100-(uint8_t)sender.value]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" | %d%% | %d%% | ", (uint8_t)sender.value, 100-(uint8_t)sender.value]; } - (void) touchPointerVelocityFactorSliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:self.pointerVelocityFactorStack].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)[self map_velocFactorDisplay_fromSliderValue:sender.value]]; // Update label display + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)[self map_velocFactorDisplay_fromSliderValue:sender.value]]; // Update label display } - (void) gyroSensitivitySliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:self.gyroSensitivityStack].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display } - (void) localVolumeSliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:self.localVolumeStack].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display if(_mainFrameViewController.settingsExpandedInStreamView) [Connection setVolume:sender.value/100]; } - (void) micVolumeSliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:self.micVolumeStack].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)sender.value]; // Update label display if(_mainFrameViewController.settingsExpandedInStreamView) [MicHandler setVolume:sender.value/100]; } @@ -3082,7 +3285,7 @@ BOOL isCustomResolution(int resolutionSelected) { labelString = [LocalizationHelper localizedStringForKey:@" keep %d min ", (uint16_t)sender.value]; if(sender.value == 0) labelString = [LocalizationHelper localizedStringForKey:@" disconnect "]; if(sender.value == sender.maximumValue) labelString = [LocalizationHelper localizedStringForKey:@" keep alive "]; - [self findDynamicLabelFromStack:self.backgroundSessionTimerStack].text = labelString; // Update label display + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = labelString; // Update label display } - (void)appThemeChanged:(UISegmentedControl* )sender{ @@ -3110,31 +3313,35 @@ BOOL isCustomResolution(int resolutionSelected) { } - (void) mousePointerVelocityFactorSliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:_mousePointerVelocityStack].text = [NSString stringWithFormat:@" %d%% ",(uint16_t)[self map_velocFactorDisplay_fromSliderValue:sender.value]]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ",(uint16_t)[self map_velocFactorDisplay_fromSliderValue:sender.value]]; } - (void) singleTapSensitivitySliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:_singleTapSensitivityStack].text = [NSString stringWithFormat:@" %.1f ",sender.value]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %.1f ",sender.value]; } - (void) scrollSensitivitySliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:_scrollSensitivityStack].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)(sender.value*100)]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)(sender.value*100)]; } - (void) pinchSensitivitySliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:_pinchSensitivityStack].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)(sender.value*100)]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)(sender.value*100)]; } - (void) relativeTouchSlideThresholdSliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:_relativeTouchSlideThresholdStack].text = [NSString stringWithFormat:@" %.1f ",sender.value]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %.1f ",sender.value]; } - (void) controllerMouseVelocitySliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:_controllerMouseVelocityStack].text = [NSString stringWithFormat:@" %.1f ", sender.value]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %.1f ", sender.value]; +} + +- (void) streamingRadialMenuDelaySliderMoved:(UISlider* )sender { + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %.1f s ", sender.value]; } - (void) controllerMouseExpoSliderMoved:(UISlider* )sender { - [self findDynamicLabelFromStack:_controllerMouseExpoStack].text = [NSString stringWithFormat:@" %.1f ", sender.value]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %.1f ", sender.value]; } - (uint32_t) getScreenEdgeFromSelector { @@ -3580,27 +3787,27 @@ BOOL isCustomResolution(int resolutionSelected) { NSInteger width = [self getChosenStreamWidth]; NSInteger height = [self getChosenStreamHeight]; - [self findDynamicLabelFromStack:_resolutionStack].text = [NSString stringWithFormat:@"%ld × %ld", (long)width, (long)height]; + [self findDynamicLabelFromStack:_resolutionSelectorStack].text = [NSString stringWithFormat:@"%ld × %ld", (long)width, (long)height]; } - (void) touchMoveEventIntervalSliderMoved:(UISlider* )sender{ - [self findDynamicLabelFromStack:self.touchMoveEventIntervalStack].text = sender.enabled ? + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = sender.enabled ? [NSString stringWithFormat:@" %d μs ", (uint16_t)self.touchMoveEventIntervalSlider.value] : @""; } - (void) leftClickDelaySliderMoved:(UISlider* )sender{ - [self findDynamicLabelFromStack:self.leftClickDelayStack].text = [NSString stringWithFormat:@" %d ms ", (uint16_t)sender.value]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d ms ", (uint16_t)sender.value]; } - (void) slideToMenuDistanceSliderMoved:(UISlider* )sender{ - UILabel* displayLabel = [self findDynamicLabelFromStack:_slideToSettingsDistanceStack]; + UILabel* displayLabel = [self findDynamicLabelFromStack:(UIStackView*)sender.superview]; // displayLabel.font = [UIFont systemFontOfSize:12 weight:UIFontWeightMedium]; NSString* labelText = [LocalizationHelper localizedStringForKey:@"%d%% screen width", (uint8_t)(sender.value * 100)]; displayLabel.text = [NSString stringWithFormat:@" %@ ", labelText]; } - (void) edgeSlidingSensitivitySliderMoved:(UISlider* )sender{ - UILabel* displayLabel = [self findDynamicLabelFromStack:_edgeSlidingSensitivityStack]; + UILabel* displayLabel = [self findDynamicLabelFromStack:(UIStackView*)sender.superview]; // displayLabel.font = [UIFont systemFontOfSize:12 weight:UIFontWeightMedium]; NSString* labelText = [LocalizationHelper localizedStringForKey:@" %d ", (uint8_t)sender.value]; displayLabel.text = [NSString stringWithFormat:@" %@ ", labelText]; @@ -3782,7 +3989,7 @@ BOOL isCustomResolution(int resolutionSelected) { - (void) frameQueueSizeSliderMoved:(UISlider* )sender { assert(self.frameQueueSizeSlider.value >= 0 && self.frameQueueSizeSlider.value <= 5); int queueSize = self.frameQueueSizeSlider.value; - [self findDynamicLabelFromStack:_frameQueueSizeStack].text = queueSize==0 ? [LocalizationHelper localizedStringForKey:@"lowest latency"] : [NSString stringWithFormat: @" %d ", queueSize]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = queueSize==0 ? [LocalizationHelper localizedStringForKey:@"lowest latency"] : [NSString stringWithFormat: @" %d ", queueSize]; } - (void) enableGraphsChanged:(UISwitch* )sender { @@ -3811,6 +4018,8 @@ BOOL isCustomResolution(int resolutionSelected) { if(![MicHandler permissionGranted]) [self.redirectMicSwitch setOn:false]; [self saveGameProfileConfigs]; + + if (@available(iOS 13.0, *)) if(!self.controllerNavigationSwitch.isOn) ControllerNavigator.enabled = false; } - (void)pencilTickModeChanged:(UISegmentedControl* )sender{ @@ -3863,7 +4072,7 @@ BOOL isCustomResolution(int resolutionSelected) { } - (void)pencilTickIntervalSliderMoved:(UISlider* )sender{ - [self findDynamicLabelFromStack:self.pencilTickIntervalStack].text = [NSString stringWithFormat:@" %d μs ", (uint16_t)sender.value]; + [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d μs ", (uint16_t)sender.value]; } - (void)pressureCurveSwitchFlipped:(UISwitch* )sender{ @@ -3964,7 +4173,7 @@ BOOL isCustomResolution(int resolutionSelected) { self.pencilTickSelector.selectedSegmentIndex = tempSettings.pencilTickMode.intValue; [self.pencilTickSelector addTarget:self action:@selector(pencilTickModeChanged:) forControlEvents:UIControlEventValueChanged]; [self.pencilTickSelector sendActionsForControlEvents:UIControlEventValueChanged]; - + [self.pencilTickIntervalSlider setValue:tempSettings.pencilTickIntervalUs.floatValue]; [self.pencilTickIntervalSlider addTarget:self action:@selector(pencilTickIntervalSliderMoved:) forControlEvents:UIControlEventValueChanged]; [self.pencilTickIntervalSlider sendActionsForControlEvents:UIControlEventValueChanged]; @@ -4079,7 +4288,7 @@ BOOL isCustomResolution(int resolutionSelected) { BOOL ctrlDownForPinch = self.ctrlDownForPinchSwitch.isOn; CGFloat leftClickDelayMs = self.leftClickDelaySlider.value; BOOL passthroughGestures = self.passthroughGesturesSwitch.isOn; - BOOL mapControllerToMouse = self.controllerToMouseSwitch.isOn; + BOOL enableControllerNavigation = self.controllerNavigationSwitch.isOn; CGFloat controllerMousePointerVelocity = self.controllerMouseVelocitySlider.value; CGFloat controllerMouseExpo = self.controllerMouseExpoSlider.value; NSInteger controllerGyroSwitchMode = self.controllerGyroSwitchButtonSetter.selectedSegmentIndex; @@ -4087,6 +4296,7 @@ BOOL isCustomResolution(int resolutionSelected) { BOOL asyncFrameDequeue = self.asyncFrameDequeueSwitch.isOn; CGFloat softKeyboardHeight = self.softKeyboardHeightSwitch.isOn ? self->softKeyboardHeight : 0; BOOL globeAsEscape = self.globeAsEscapeSwitch.isOn; + CGFloat streamingRadialMenuDelay = self.streamingRadialMenuDelaySlider.value; NSInteger backgroundSessionTimer = self.backgroundSessionTimerSlider.value == self.backgroundSessionTimerSlider.maximumValue ? (uint32_t) INT16_MAX : (uint32_t)self.backgroundSessionTimerSlider.value; [dataMan saveSettings:currentSettings @@ -4156,7 +4366,7 @@ BOOL isCustomResolution(int resolutionSelected) { leftClickDelayMs:leftClickDelayMs settingsMenuOffset:settingsMenuOffset passthroughGestures:passthroughGestures - mapControllerToMouse:mapControllerToMouse + enableControllerNavigation:enableControllerNavigation controllerMousePointerVelocity:controllerMousePointerVelocity controllerMouseExpo:controllerMouseExpo controllerGyroSwitchMode:controllerGyroSwitchMode @@ -4164,7 +4374,8 @@ BOOL isCustomResolution(int resolutionSelected) { asyncFrameDequeue:asyncFrameDequeue sdrPerformanceWorkaround:sdrPerformanceWorkaround softKeyboardHeight:softKeyboardHeight - globeAsEscape:globeAsEscape + globeAsEscape:globeAsEscape + streamingRadialMenuDelay:streamingRadialMenuDelay backgroundSessionTimer:backgroundSessionTimer]; } diff --git a/VoidLink/ViewControllers/StreamFrameViewController.h b/VoidLink/ViewControllers/StreamFrameViewController.h index 1fe01e8f..9c6c6e5c 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.h +++ b/VoidLink/ViewControllers/StreamFrameViewController.h @@ -28,7 +28,7 @@ @interface StreamFrameViewController : GCEventViewController #else -@interface StreamFrameViewController : UIViewController +@interface StreamFrameViewController : UIViewController #endif @property (nonatomic, strong) StreamManager* streamMan; @@ -48,14 +48,19 @@ @property (nonatomic, assign) CGFloat streamViewMagnifierZoomScale; ++ (StreamFrameViewController *)sharedInstance; - (void)updatePreferredDisplayMode:(BOOL)streamActive; - (void)setUserInteractionEnabledForStreamView:(bool)enabled; - (bool)shallDisableGyroHotSwitch; - (void)loadGameProfileConfigs:(OSCProfile* )profile; +- (void)openWidgetProfileTableWithPickProfile:(BOOL)pickProfile; - (void)toggleGamepadOverlayWithOverlayEnabled:(BOOL)overlayEnabled API_AVAILABLE(ios(13.0)); - (void)loadAbstractGamepadOverlayIfNeeded API_AVAILABLE(ios(13.0)); - (void)restorePersistedStreamViewOffsetAndScaleWithProfile:(OSCProfile* )profile; - (void)updateMagnifierViewportMetrics; +- (void)setMagnifierViewportInteractionEnabled:(BOOL)enabled; +- (void)resetMagnifierStreamViewWithAnimated:(BOOL)animated; +- (void)returnToMainFrame; @end diff --git a/VoidLink/ViewControllers/StreamFrameViewController.m b/VoidLink/ViewControllers/StreamFrameViewController.m index 997ed7e5..87c1deb7 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.m +++ b/VoidLink/ViewControllers/StreamFrameViewController.m @@ -76,6 +76,11 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { } +@interface StreamFrameViewController () +@end + +static __weak StreamFrameViewController *VLSharedStreamFrameViewController = nil; + @implementation StreamFrameViewController { ControllerSupport *_controllerSupport; TemporarySettings *_settings; @@ -130,6 +135,10 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { } ++ (StreamFrameViewController *)sharedInstance { + return VLSharedStreamFrameViewController; +} + - (void)pictureInPictureControllerWillStartPictureInPicture:(AVPictureInPictureController *)pictureInPictureController { _streamView.hidden = YES; if (self.imguiView) { @@ -660,6 +669,12 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { [self setMagnifierViewportInteractionEnabled:_oscProfile.touchMode == AbsoluteTouch && !_settings.passthroughGestures]; GenericUtils.globeAsEscape = _settings.globeAsEscape; + + if (@available(iOS 13.0, *)) { + ControllerNavigator.localRadialMenuButton = (ControllerElement)_settings.localRadialMenuButton.intValue; + ControllerNavigator.streamingRadialMenuButton = (ControllerElement)_settings.streamingRadialMenuButton.intValue; + ControllerNavigator.streamingRadialMenuDelay = (NSTimeInterval)_settings.streamingRadialMenuDelay.floatValue; + } NSLog(@"frameview gestures: %d", (uint32_t)[self.view.gestureRecognizers count]); NSLog(@"streamview gestures: %d", (uint32_t)[_streamView.gestureRecognizers count]); @@ -828,6 +843,7 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { - (void)viewDidLoad { + VLSharedStreamFrameViewController = self; _viewJustLoaded = true; viewIsBeingResized = false; _magnifierViewportInteractionActive = false; @@ -1005,8 +1021,6 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { dispatch_after(delay, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ OnScreenWidgetView.enableFolderAnimation = true; }); - - [ControllerNavigator stop]; } - (void)keyboardWillShow:(NSNotification *)notification{ @@ -1118,10 +1132,12 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { if(!profile){ profile = [OSCProfilesManager sharedManager:CGRectZero].getSelectedProfile; } - [self setMagnifierViewportInteractionEnabled:true]; - CGPoint streamViewOffset = CGPointMake(profile.normalizedStreamViewOffset.x*self.view.bounds.size.width, profile.normalizedStreamViewOffset.y*self.view.bounds.size.height); - [self restoreMagnifierStreamViewWithOffset:streamViewOffset scale:profile.streamViewScale animated:YES]; - [self setMagnifierViewportInteractionEnabled:profile.touchMode == AbsoluteTouch && !_settings.passthroughGestures]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self setMagnifierViewportInteractionEnabled:true]; + CGPoint streamViewOffset = CGPointMake(profile.normalizedStreamViewOffset.x*self.view.bounds.size.width, profile.normalizedStreamViewOffset.y*self.view.bounds.size.height); + [self restoreMagnifierStreamViewWithOffset:streamViewOffset scale:profile.streamViewScale animated:YES]; + [self setMagnifierViewportInteractionEnabled:profile.touchMode == AbsoluteTouch && !self->_settings.passthroughGestures]; + }); } - (void)restoreMagnifierStreamViewWithOffset:(CGPoint)offset scale:(CGFloat)scale { @@ -1289,10 +1305,10 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { - (void)setupOverlayView{ if (_overlayView == nil) { _overlayView = [[PaddedLabel alloc] initWithFrame:CGRectZero]; - [_overlayView setTextInsets:UIEdgeInsetsMake([_mainFrameViewcontroller isIPhone]?4:6, 12, [_mainFrameViewcontroller isIPhone]?4:6, 12)]; + [_overlayView setTextInsets:UIEdgeInsetsMake(PublicUtils.isIPhone?4:6, 12, PublicUtils.isIPhone?4:6, 12)]; [_overlayView setUserInteractionEnabled:NO]; [_overlayView setNumberOfLines:100]; - [_overlayView.layer setCornerRadius:[_mainFrameViewcontroller isIPhone]?7:10]; + [_overlayView.layer setCornerRadius:PublicUtils.isIPhone?7:10]; [_overlayView.layer setMasksToBounds:YES]; // HACK: If not using stats overlay, center the text @@ -1305,13 +1321,13 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { #if TARGET_OS_TV [_overlayView setFont:[UIFont systemFontOfSize:24 weight:UIFontWeightMedium]]; #else - [_overlayView setFont:[UIFont systemFontOfSize: [_mainFrameViewcontroller isIPhone]?10:12 weight:UIFontWeightMedium]]; + [_overlayView setFont:[UIFont systemFontOfSize: PublicUtils.isIPhone?10:12 weight:UIFontWeightMedium]]; #endif [_overlayView setAlpha:(float)[_settings.graphOpacity intValue]/ 100.0]; [self.view addSubview:_overlayView]; } if (@available(iOS 13.0, *)) { - if(overlayLevel == 1) _overlayView.font = [UIFont monospacedSystemFontOfSize:[_mainFrameViewcontroller isIPhone]?10:12 weight:UIFontWeightMedium]; + if(overlayLevel == 1) _overlayView.font = [UIFont monospacedSystemFontOfSize:PublicUtils.isIPhone?10:12 weight:UIFontWeightMedium]; } [_overlayView setHidden:YES]; @@ -1336,7 +1352,18 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { } } -- (void) returnToMainFrame { +- (void)returnToMainFrame { + if (@available(iOS 13.0, *)) { + // [ControllerNavigator setUINavigationDelegate:[_mainFrameViewcontroller isInAppView] ? _mainFrameViewcontroller : _mainFrameViewcontroller.hostCollectionVC]; + [ControllerNavigator restorePreviousUINavigationDelegateWithIfCurrentDelegateIs:self]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.15 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if(ControllerNavigator.radialMenuView) [ControllerNavigator updateRadialMenu]; + }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [ControllerNavigator restoreUINavigationHighlight]; + }); + } + [_streamView saveStreamingGameProfileChanges]; [_streamView clearOnScreenWidgets]; if(micHandler) [micHandler clean]; @@ -1347,11 +1374,11 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { if (@available(iOS 13.0, *)) { [SceneDelegate clearExternalDisplayRenderView]; } - + if (_settings.enablePIP) { [self cleanupPiPController]; } - + [_statsUpdateTimer invalidate]; _statsUpdateTimer = nil; @@ -1360,8 +1387,10 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { _extWindow = nil; if(_streamConfig.redirectMic) [micHandler stopTappingWithStopEngine:true]; - + self.mainFrameViewcontroller.settingsExpandedInStreamView = false; // reset this flag to false + + [ControllerUtil disableSysGestures: ControllerUtil.primaryGCController]; } // External Screen connected @@ -1569,7 +1598,6 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { } - (void)expandSettingsView{ - self.mainFrameViewcontroller.settingsExpandedInStreamView = true; //notify mainFrameViewContorller that this is a setting expansion in stream view, some settings shall be disabled. [_streamView saveStreamingGameProfileChanges]; [self.mainFrameViewcontroller expandSettingsView]; } @@ -1593,7 +1621,7 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ sleep(1.5); dispatch_async(dispatch_get_main_queue(), ^{ - [self.mainFrameViewcontroller quitRunningApp]; + [self.mainFrameViewcontroller quitLaunchedApp]; }); }); } @@ -2258,6 +2286,9 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { - (void)dealloc { + if (VLSharedStreamFrameViewController == self) { + VLSharedStreamFrameViewController = nil; + } NSLog(@"dealloc StreamFrameViewController %f", CACurrentMediaTime()); } @@ -2271,4 +2302,5 @@ static NSString* VLTerminationHintForErrorCode(int errorCode) { }]; } + @end diff --git a/VoidLink/ViewControllers/ToolboxViewController.swift b/VoidLink/ViewControllers/ToolboxViewController.swift index b024c2c0..9a3ab1a7 100644 --- a/VoidLink/ViewControllers/ToolboxViewController.swift +++ b/VoidLink/ViewControllers/ToolboxViewController.swift @@ -463,7 +463,7 @@ import UIKit private func sendKeyboardCommand(_ cmd: RemoteCommand) { - print("Sending key-value") + // print("Sending key-value") let keyboardCmdStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: cmd.cmdString) CommandManager.shared.sendAutoReleaseComboCommand(cmdStrings: keyboardCmdStrings!) } diff --git a/VoidLink/VoidLink-Bridging-Header.h b/VoidLink/VoidLink-Bridging-Header.h index 5e1353ed..0aca0770 100644 --- a/VoidLink/VoidLink-Bridging-Header.h +++ b/VoidLink/VoidLink-Bridging-Header.h @@ -3,9 +3,14 @@ // #include +#import "SettingsViewController.h" +#import "MainFrameViewController.h" +#import "StreamFrameViewController.h" +#import "ControllerSupport.h" #import "OnScreenControls.h" #import "OnScreenButtonState.h" #import "DataManager.h" +#import "AppAssetManager.h" #import "LocalizationHelper.h" #import "OSCProfile.h" #import "LayoutOnScreenControls.h" diff --git a/VoidLink/mul.lproj/iPad.xcstrings b/VoidLink/mul.lproj/iPad.xcstrings index b357ff04..0e8b4b16 100644 --- a/VoidLink/mul.lproj/iPad.xcstrings +++ b/VoidLink/mul.lproj/iPad.xcstrings @@ -733,6 +733,36 @@ } } }, + "9d1-RN-lqW.text" : { + "comment" : "Class = \"UILabel\"; text = \"Streaming Radial Menu Delay\"; ObjectID = \"9d1-RN-lqW\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Streaming Radial Menu Delay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "串流手柄菜单延迟" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "串流手把選單延遲" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "串流手把選單延遲" + } + } + } + }, "9pX-93-bC2.text" : { "comment" : "Class = \"UILabel\"; text = \"Background Session\"; ObjectID = \"9pX-93-bC2\";", "extractionState" : "extracted_with_value", @@ -5313,31 +5343,31 @@ } }, "P1g-rW-Mgp.text" : { - "comment" : "Class = \"UILabel\"; text = \"Controller → Mouse\"; ObjectID = \"P1g-rW-Mgp\";", + "comment" : "Class = \"UILabel\"; text = \"Controller Navigation\"; ObjectID = \"P1g-rW-Mgp\";", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Controller → Mouse" + "value" : "Controller Navigation" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "手柄映射为鼠标" + "value" : "手柄导航" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "手把映射為滑鼠" + "value" : "手把導覽" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "手把映射為滑鼠" + "value" : "手把導覽" } } } @@ -6959,31 +6989,31 @@ } }, "Xnd-C4-8N9.text" : { - "comment" : "Class = \"UILabel\"; text = \"Pointer Velocity\"; ObjectID = \"Xnd-C4-8N9\";", + "comment" : "Class = \"UILabel\"; text = \"Controller Mouse Velocity\"; ObjectID = \"Xnd-C4-8N9\";", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Pointer Velocity" + "value" : "Controller Mouse Velocity" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "指针速度" + "value" : "手柄鼠标速度" } }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", - "value" : "指針速度" + "state" : "translated", + "value" : "手把滑鼠速度" } }, "zh-HK" : { "stringUnit" : { - "state" : "needs_review", - "value" : "指針速度" + "state" : "translated", + "value" : "手把滑鼠速度" } } } diff --git a/VoidLink/mul.lproj/iPhone.xcstrings b/VoidLink/mul.lproj/iPhone.xcstrings index a3be7278..ab62edf4 100644 --- a/VoidLink/mul.lproj/iPhone.xcstrings +++ b/VoidLink/mul.lproj/iPhone.xcstrings @@ -1621,6 +1621,36 @@ } } }, + "bPX-Kf-kdP.text" : { + "comment" : "Class = \"UILabel\"; text = \"Streaming Radial Menu Delay\"; ObjectID = \"bPX-Kf-kdP\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Streaming Radial Menu Delay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "串流手柄菜单延迟" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "串流手把選單延遲" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "串流手把選單延遲" + } + } + } + }, "BQ1-w6-Opg.text" : { "comment" : "Class = \"UILabel\"; text = \"Touch Point Tracking\"; ObjectID = \"BQ1-w6-Opg\";", "extractionState" : "extracted_with_value", @@ -2402,31 +2432,31 @@ } }, "F0O-Ht-fW5.text" : { - "comment" : "Class = \"UILabel\"; text = \"Controller → Mouse\"; ObjectID = \"F0O-Ht-fW5\";", + "comment" : "Class = \"UILabel\"; text = \"Controller Navigation\"; ObjectID = \"F0O-Ht-fW5\";", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Controller → Mouse" + "value" : "Controller Navigation" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "手柄映射为鼠标" + "value" : "手柄导航" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "手把映射為滑鼠" + "value" : "手把導覽" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "手把映射為滑鼠" + "value" : "手把導覽" } } } @@ -2738,31 +2768,31 @@ } }, "H3s-ud-yqh.text" : { - "comment" : "Class = \"UILabel\"; text = \"Pointer Velocity\"; ObjectID = \"H3s-ud-yqh\";", + "comment" : "Class = \"UILabel\"; text = \"Controller Mouse Velocity\"; ObjectID = \"H3s-ud-yqh\";", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Pointer Velocity" + "value" : "Controller Mouse Velocity" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "指针速度" + "value" : "手柄鼠标速度" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "指針速度" + "value" : "手把滑鼠速度" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "指針速度" + "value" : "手把滑鼠速度" } } } diff --git a/VoidLinkPreview/AbstractGamepadView.swift b/VoidLinkPreview/AbstractGamepadView.swift index 22f77a8b..9619e0fd 100644 --- a/VoidLinkPreview/AbstractGamepadView.swift +++ b/VoidLinkPreview/AbstractGamepadView.swift @@ -518,7 +518,7 @@ struct AbstractGamepadView: View { applyTriggerSelection(widget) applyDS4TouchSelection(widget) - print("selectedCmd =", AbstractGamepadView.selectedCmd) + // print("selectedCmd =", AbstractGamepadView.selectedCmd) return true } @@ -733,8 +733,8 @@ struct AbstractGamepadView: View { if liveSnapshot.pressedButtons.contains(.b) { result.insert(.b) } if liveSnapshot.pressedButtons.contains(.x) { result.insert(.x) } if liveSnapshot.pressedButtons.contains(.y) { result.insert(.y) } - if liveSnapshot.pressedButtons.contains(.menu) { result.insert(.start) } - if liveSnapshot.pressedButtons.contains(.back) { result.insert(.select) } + if liveSnapshot.pressedButtons.contains(.start) { result.insert(.start) } + if liveSnapshot.pressedButtons.contains(.select) { result.insert(.select) } if liveSnapshot.pressedButtons.contains(.special) { result.insert(.home) } if liveSnapshot.pressedButtons.contains(.leftShoulder) { result.insert(.leftShoulder) } if liveSnapshot.pressedButtons.contains(.rightShoulder) { result.insert(.rightShoulder) } diff --git a/VoidLinkPreview/RadialMenuView.swift b/VoidLinkPreview/RadialMenuView.swift index ab25588b..09e632e7 100644 --- a/VoidLinkPreview/RadialMenuView.swift +++ b/VoidLinkPreview/RadialMenuView.swift @@ -10,25 +10,42 @@ import SwiftUI import Combine import UIKit -public enum RadialMenuItem { +@objc public enum RadialMenuItem:Int { case settings + case favoriteSettings + case allSettings + case addHost + case aboutView case hostView case gameProfiles + case disconnect + case mouse + case quitApp + case theme + case more + case navigationSettings case exit } +@objc public enum RadialMenuState:Int { + case main + case mouseModeEnabled + case disconnectAndQuit + case moreOptions +} + @available(iOS 13.0, *) struct RadialMenuSector: Identifiable, Equatable { let id = UUID() let title: String let subtitle: String - let systemImageName: String + let symbolName: String let item: RadialMenuItem - init(title: String, subtitle: String, systemImageName: String, item: RadialMenuItem) { + init(title: String, subtitle: String, symbol: String, item: RadialMenuItem) { self.title = title self.subtitle = subtitle - self.systemImageName = systemImageName + self.symbolName = symbol self.item = item } } @@ -38,7 +55,7 @@ struct RadialMenuStyle: Equatable { var ringColor = Color(white: 0.86) var selectedRingColor = Color(red: 0.19, green: 0.72, blue: 0.96) var ringStrokeColor = Color.white.opacity(0.72) - var centerFillColor = Color.white + var centerFillColor = Color(ThemeManager.hostViewBackgroundColor.withAlphaComponent(0.5)) var iconColor = Color(white: 0.23) var selectedIconColor = Color(white: 0.16) var titleColor = Color(white: 0.24) @@ -46,35 +63,97 @@ struct RadialMenuStyle: Equatable { var shadowColor = Color.black.opacity(0.18) var ringWidthRatio: CGFloat = 0.41 var segmentGapWidth: CGFloat = 1 - var centerIconScale: CGFloat = 0.16 + var centerIconScale: CGFloat = 0.13 var segmentIconScale: CGFloat = 0.085 + + static func themed(for userInterfaceStyle: UIUserInterfaceStyle, accentColor: UIColor) -> RadialMenuStyle { + switch userInterfaceStyle { + case .dark: + return RadialMenuStyle( + ringColor: Color(UIColor(red: 44.0 / 255.0, green: 44.0 / 255.0, blue: 46.0 / 255.0, alpha: 0.88)), + selectedRingColor: Color(accentColor.withAlphaComponent(0.52)), + ringStrokeColor: Color.white.opacity(0.26), + // centerFillColor: Color(UIColor(red: 28.0 / 255.0, green: 28.0 / 255.0, blue: 30.0 / 255.0, alpha: 0.96)), + centerFillColor: Color(ThemeManager.hostViewBackgroundColor.withAlphaComponent(0.5)), + iconColor: Color(UIColor(white: 0.86, alpha: 1)), + selectedIconColor: Color.white, + titleColor: Color(UIColor(white: 0.92, alpha: 1)), + subtitleColor: Color(UIColor(white: 0.70, alpha: 1)), + shadowColor: Color(accentColor.withAlphaComponent(0)), + ringWidthRatio: 0.41, + segmentGapWidth: 1, + centerIconScale: 0.16, + segmentIconScale: 0.085 + ) + default: + return RadialMenuStyle( + selectedRingColor: Color(accentColor.withAlphaComponent(0.86)) + ) + } + } +} + +@available(iOS 13.0, *) +enum RadialMenuSelectionChangeReason { + case began + case moved + case released + case unchanged } @available(iOS 13.0, *) final class RadialMenuSelectionState: ObservableObject { @Published private(set) var selectedIndex: Int? @Published private(set) var hasReceivedJoystickInput = false + @Published private(set) var lastChangeReason: RadialMenuSelectionChangeReason = .unchanged private var itemCount = 0 - func updateSelection(xOffset: Float, yOffset: Float) { + func updateSelection(xOffset: CGFloat, yOffset: CGFloat) { updateSelection(xOffset: xOffset, yOffset: yOffset, deadZone: 0.1) } - func updateSelection(xOffset: Float, yOffset: Float, deadZone: Float) { + func updateSelection(xOffset: CGFloat, yOffset: CGFloat, deadZone: CGFloat) { + let x = max(min(xOffset, 1), -1) + let y = max(min(yOffset, 1), -1) + let distance = hypot(x, y) + if distance < deadZone { + guard !hasReceivedJoystickInput || selectedIndex != nil else { + lastChangeReason = .unchanged + return + } + + hasReceivedJoystickInput = true + lastChangeReason = selectedIndex == nil ? .unchanged : .released + selectedIndex = nil + return + } + + // Near center, tiny return-to-center offsets have unstable angles. Keep the + // last high-confidence sector until the stick fully enters the dead zone. + let selectionUpdateDeadZone = max(deadZone, 0.35) + guard distance >= selectionUpdateDeadZone else { + hasReceivedJoystickInput = true + lastChangeReason = .unchanged + return + } + let nextSelectedIndex = Self.selectedIndex( - xOffset: xOffset, - yOffset: yOffset, + xOffset: x, + yOffset: y, itemCount: itemCount, deadZone: deadZone ) guard !hasReceivedJoystickInput || nextSelectedIndex != selectedIndex else { + lastChangeReason = .unchanged return } + let previousSelectedIndex = selectedIndex hasReceivedJoystickInput = true selectedIndex = nextSelectedIndex + lastChangeReason = Self.changeReason(from: previousSelectedIndex, to: nextSelectedIndex) } fileprivate func configureItemCount(_ itemCount: Int) { @@ -84,7 +163,7 @@ final class RadialMenuSelectionState: ObservableObject { } } - static func selectedIndex(xOffset: Float, yOffset: Float, itemCount: Int, deadZone: Float = 0.1) -> Int? { + static func selectedIndex(xOffset: CGFloat, yOffset: CGFloat, itemCount: Int, deadZone: CGFloat = 0.1) -> Int? { guard itemCount > 0 else { return nil } let x = max(min(xOffset, 1), -1) @@ -104,6 +183,19 @@ final class RadialMenuSelectionState: ObservableObject { let remainder = value.truncatingRemainder(dividingBy: divisor) return remainder >= 0 ? remainder : remainder + divisor } + + private static func changeReason(from previousIndex: Int?, to nextIndex: Int?) -> RadialMenuSelectionChangeReason { + switch (previousIndex, nextIndex) { + case (nil, .some): + return .began + case (.some(let previous), .some(let next)) where previous != next: + return .moved + case (.some, nil): + return .released + default: + return .unchanged + } + } } @available(iOS 13.0, *) @@ -151,7 +243,7 @@ struct RadialMenuView: View { self.joystickSelectionState.configureItemCount(sectors.count) } - func updateSelection(xOffset: Float, yOffset: Float) { + func updateSelection(xOffset: CGFloat, yOffset: CGFloat) { joystickSelectionState.configureItemCount(sectors.count) joystickSelectionState.updateSelection(xOffset: xOffset, yOffset: yOffset) } @@ -192,9 +284,13 @@ struct RadialMenuView: View { ForEach(sectors.indices, id: \.self) { index in let item = sectors[index] - Image(systemName: item.systemImageName) - .font(.system(size: max(diameter * style.segmentIconScale, 18), weight: .medium)) - .foregroundColor(index == effectiveSelectedIndex ? style.selectedIconColor : style.iconColor) + RadialMenuIconView( + symbolName: item.symbolName, + fallbackSystemName: "circle", + size: max(diameter * style.segmentIconScale, 18), + weight: .medium, + color: index == effectiveSelectedIndex ? style.selectedIconColor : style.iconColor + ) .scaleEffect(index == effectiveSelectedIndex ? 1.18 : 1) .position(iconPosition(index: index, count: sectors.count, center: center, radius: (outerRadius + innerRadius) / 2)) .animation(.spring(response: 0.24, dampingFraction: 0.72), value: effectiveSelectedIndex) @@ -203,7 +299,7 @@ struct RadialMenuView: View { RadialMenuCenterView(item: selectedItem, style: style, diameter: diameter) .frame(width: innerRadius * 1.72, height: innerRadius * 1.72) .position(center) - .animation(.easeInOut(duration: 0.16), value: effectiveSelectedIndex) + .animation(.easeInOut(duration: selectedItem?.item == .gameProfiles ? 0 : 0.16), value: effectiveSelectedIndex) } .frame(width: proxy.size.width, height: proxy.size.height) .contentShape(Circle()) @@ -266,18 +362,25 @@ private struct RadialMenuCenterView: View { let diameter: CGFloat var body: some View { - VStack(spacing: max(diameter * 0.018, 4)) { - Image(systemName: item?.systemImageName ?? "circle.grid.cross") - .font(.system(size: max(diameter * style.centerIconScale, 28), weight: .medium)) - .foregroundColor(style.iconColor) + VStack(spacing: 0) { + RadialMenuIconView( + symbolName: item?.symbolName ?? "circle.grid.cross", + fallbackSystemName: "circle.grid.cross", + size: centerIconSize, + weight: .medium, + color: style.iconColor + ) .id(item?.id) .transition(.opacity) + .frame(width: centerIconSize * 1.35, height: centerIconSize * 1.2) + .padding(.bottom, centerIconTitleSpacing) Text(item?.title ?? "") .font(.system(size: max(diameter * 0.047, 13), weight: .medium)) .foregroundColor(style.titleColor) .lineLimit(1) .minimumScaleFactor(0.62) + .padding(.bottom, item?.subtitle.isEmpty == false ? centerTextSpacing : 0) if item?.subtitle.isEmpty == false { Text(item?.subtitle ?? "") @@ -290,6 +393,114 @@ private struct RadialMenuCenterView: View { .multilineTextAlignment(.center) .padding(.horizontal, diameter * 0.035) } + + private var centerIconSize: CGFloat { + max(diameter * style.centerIconScale, 28) + } + + private var centerTextSpacing: CGFloat { + max(diameter * 0.018, 4) + } + + private var centerIconTitleSpacing: CGFloat { + if #available(iOS 14.0, *) { + return centerTextSpacing + } + + return max(diameter * 0.034, 8) + } +} + +@available(iOS 13.0, *) +private struct RadialMenuIconView: View { + let symbolName: String + let fallbackSystemName: String + let size: CGFloat + let weight: Font.Weight + let color: Color + + var body: some View { + Group { + if UIImage(systemName: symbolName) != nil { + Image(systemName: symbolName) + .font(.system(size: size, weight: weight)) + } else if let assetImage = RadialMenuIconImageRenderer.templateImage(named: symbolName, pointSize: size) { + Image(uiImage: assetImage) + .renderingMode(.template) + } else { + Image(systemName: fallbackSystemName) + .font(.system(size: size, weight: weight)) + } + } + .foregroundColor(color) + } +} + +@available(iOS 13.0, *) +private enum RadialMenuIconImageRenderer { + private static let cache = NSCache() + private static let assetCanvasScale: CGFloat = 1.05 + private static let assetVisualScale: CGFloat = 1 + + static func templateImage(named name: String, pointSize: CGFloat) -> UIImage? { + let scale = UIScreen.main.scale + let normalizedPointSize = max(ceil(pointSize * 2) / 2, 1) + let cacheKey = "\(name)-\(normalizedPointSize)-\(scale)-\(assetCanvasScale)-\(assetVisualScale)" as NSString + + if let cachedImage = cache.object(forKey: cacheKey) { + return cachedImage + } + + guard let sourceImage = UIImage(named: name) else { + return nil + } + + let canvasSize = CGSize( + width: normalizedPointSize * assetCanvasScale, + height: normalizedPointSize * assetCanvasScale + ) + let format = UIGraphicsImageRendererFormat() + format.scale = scale + format.opaque = false + + let renderedImage = UIGraphicsImageRenderer(size: canvasSize, format: format).image { _ in + let drawBounds = scaledRect( + CGRect(origin: .zero, size: canvasSize), + by: assetVisualScale + ) + sourceImage.draw(in: aspectFitRect(for: sourceImage.size, in: drawBounds)) + }.withRenderingMode(.alwaysTemplate) + + cache.setObject(renderedImage, forKey: cacheKey) + return renderedImage + } + + private static func scaledRect(_ rect: CGRect, by scale: CGFloat) -> CGRect { + let width = rect.width * scale + let height = rect.height * scale + return CGRect( + x: rect.midX - width / 2, + y: rect.midY - height / 2, + width: width, + height: height + ) + } + + private static func aspectFitRect(for sourceSize: CGSize, in bounds: CGRect) -> CGRect { + guard sourceSize.width > 0, sourceSize.height > 0 else { + return bounds + } + + let scale = min(bounds.width / sourceSize.width, bounds.height / sourceSize.height) + let width = sourceSize.width * scale + let height = sourceSize.height * scale + return CGRect( + x: bounds.midX - width / 2, + y: bounds.midY - height / 2, + width: width, + height: height + ) + } } @available(iOS 13.0, *) @@ -387,10 +598,10 @@ struct RadialMenuDemoView: View { @SwiftUI.State private var isTouchSelectionEnabled = true static let menuItems = [ - RadialMenuSector(title: "Settings Menu".localized, subtitle: "", systemImageName: "sidebar.left", item: .settings), - RadialMenuSector(title: "Host View".localized, subtitle: "", systemImageName: PublicUtils.liquidGlassEnabled ? "macwindow.on.rectangle" : "tv", item: .hostView), - RadialMenuSector(title: "Game Profiles".localized, subtitle: "", systemImageName: "gamecontroller.circle", item: .gameProfiles), - RadialMenuSector(title: "Exit/Disconnect".localized, subtitle: "", systemImageName: "personalhotspot.slash", item: .gameProfiles), + RadialMenuSector(title: "Settings Menu".localized, subtitle: "", symbol: "sidebar.left", item: .settings), + RadialMenuSector(title: "Host View".localized, subtitle: "", symbol: PublicUtils.liquidGlassEnabled ? "macwindow.on.rectangle" : "tv", item: .hostView), + RadialMenuSector(title: "Game Profiles".localized, subtitle: "", symbol: "gamecontroller.circle", item: .gameProfiles), + RadialMenuSector(title: "Exit/Disconnect".localized, subtitle: "", symbol: "personalhotspot.slash", item: .gameProfiles), ] private var visibleItems: [RadialMenuSector] { diff --git a/VoidLinkPreview/VirtualKeyboardView.swift b/VoidLinkPreview/VirtualKeyboardView.swift index 3742eb60..17252076 100644 --- a/VoidLinkPreview/VirtualKeyboardView.swift +++ b/VoidLinkPreview/VirtualKeyboardView.swift @@ -1568,7 +1568,7 @@ struct KeyView: View { .onEnded { _ in isPressed = false action?() - print("cmdString \(String(describing: cmdString)) code \(String(describing: keyCode))") + // print("cmdString \(String(describing: cmdString)) code \(String(describing: keyCode))") } ) } diff --git a/VoidLinkPreview/WidgetPickerView.swift b/VoidLinkPreview/WidgetPickerView.swift index 93bf6f46..7389182b 100644 --- a/VoidLinkPreview/WidgetPickerView.swift +++ b/VoidLinkPreview/WidgetPickerView.swift @@ -777,7 +777,7 @@ struct WidgetPickerView: View { FunctionalButtonOption( localizationKey: "=disableTilt", cmd: "DISABLETILT", - tip: LocalizationHelper.localizedString(forKey: "Disable tilt for apple pencil"), + tip: "Disable tilt for apple pencil".localized, allowsKeyboardCombination: false, allowsGamepadCombination: false, allowsSkillCombo: false,