diff --git a/VoidLink.xcodeproj/project.pbxproj b/VoidLink.xcodeproj/project.pbxproj index 846753fc..aaeb7233 100644 --- a/VoidLink.xcodeproj/project.pbxproj +++ b/VoidLink.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 18D43D7F2F050240008C326F /* PressureCurve.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18D43D7E2F050240008C326F /* PressureCurve.swift */; }; 18E4BCEA2E891895000A8657 /* MotionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E4BCE92E891895000A8657 /* MotionHandler.swift */; }; 18FCADAF2ECAC23200787A96 /* PencilHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FCADAE2ECAC23200787A96 /* PencilHandler.swift */; }; + 2A4B8D922F8149E200497966 /* CommandManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E1CDEB2C4F67D300547904 /* CommandManager.swift */; }; 2A7113FD2F5A6C65001D852D /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7113FC2F5A6C65001D852D /* ThemeManager.swift */; }; 2AAA4E822F7666C5001E20DC /* ControllerUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A765BC2ECD51FB004784AA /* ControllerUtil.swift */; }; 2AAA4E842F7666EE001E20DC /* SwiftLocalizationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E1CDF92C4FE83400547904 /* SwiftLocalizationHelper.swift */; }; @@ -98,6 +99,7 @@ 98CFB82F1CAD481B0048EF74 /* libmoonlight-common.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 98AB2E841CAD46840089BB98 /* libmoonlight-common.a */; }; 98D5856D1C0EA79600F6CC00 /* TemporaryHost.m in Sources */ = {isa = PBXBuildFile; fileRef = 98D5856C1C0EA79600F6CC00 /* TemporaryHost.m */; }; 98D585701C0ED0E800F6CC00 /* TemporarySettings.m in Sources */ = {isa = PBXBuildFile; fileRef = 98D5856F1C0ED0E800F6CC00 /* TemporarySettings.m */; }; + A1B2C3D42F90000100ABCDEF /* AbstractGamepadOverlayWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D32F90000100ABCDEF /* AbstractGamepadOverlayWidgetView.swift */; }; DC1F5A07206436B20037755F /* ConnectionHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = DC1F5A06206436B20037755F /* ConnectionHelper.m */; }; F74BEF9C2C1A705600224667 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F74BEF9B2C1A705600224667 /* OpenSSL */; }; FB1A674D2131E65900507771 /* KeyboardSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = FB1A674C2131E65900507771 /* KeyboardSupport.m */; }; @@ -200,7 +202,7 @@ 18D43D7E2F050240008C326F /* PressureCurve.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PressureCurve.swift; sourceTree = ""; }; 18E4BCE92E891895000A8657 /* MotionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MotionHandler.swift; sourceTree = ""; }; 18FCADAE2ECAC23200787A96 /* PencilHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PencilHandler.swift; sourceTree = ""; }; - 2A039E562F72892B00546768 /* VoidLinkSwiftUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoidLinkSwiftUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A039E562F72892B00546768 /* VoidLinkPreview.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoidLinkPreview.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2A7113FC2F5A6C65001D852D /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; 2AC1E3822F507D80004F76C0 /* GenericUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericUtils.swift; sourceTree = ""; }; 3A4A3A102F0C000100000001 /* OSCProfilesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSCProfilesTableViewController.swift; sourceTree = ""; }; @@ -314,6 +316,7 @@ 98D5856C1C0EA79600F6CC00 /* TemporaryHost.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TemporaryHost.m; path = Database/TemporaryHost.m; sourceTree = ""; }; 98D5856E1C0ED0E800F6CC00 /* TemporarySettings.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = TemporarySettings.h; path = Database/TemporarySettings.h; sourceTree = ""; }; 98D5856F1C0ED0E800F6CC00 /* TemporarySettings.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TemporarySettings.m; path = Database/TemporarySettings.m; sourceTree = ""; }; + A1B2C3D32F90000100ABCDEF /* AbstractGamepadOverlayWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbstractGamepadOverlayWidgetView.swift; sourceTree = ""; }; DC1F5A05206436B10037755F /* ConnectionHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ConnectionHelper.h; sourceTree = ""; }; DC1F5A06206436B20037755F /* ConnectionHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConnectionHelper.m; sourceTree = ""; }; FB1A674B2131E65900507771 /* KeyboardSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KeyboardSupport.h; sourceTree = ""; }; @@ -418,7 +421,7 @@ membershipExceptions = ( WidgetPickerViewController.swift, ); - target = 2A039E552F72892B00546768 /* VoidLinkSwiftUI */; + target = 2A039E552F72892B00546768 /* VoidLinkPreview */; }; 2AAA4E862F76BDC6001E20DC /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; @@ -622,7 +625,7 @@ isa = PBXGroup; children = ( FB290CEE19B2C406004C83CF /* VoidLink.app */, - 2A039E562F72892B00546768 /* VoidLinkSwiftUI.app */, + 2A039E562F72892B00546768 /* VoidLinkPreview.app */, ); name = Products; sourceTree = ""; @@ -770,6 +773,7 @@ 18E4BCE92E891895000A8657 /* MotionHandler.swift */, 84E1CDEB2C4F67D300547904 /* CommandManager.swift */, 84203F222C5F24C00032EEED /* OnScreenWidgetView.swift */, + A1B2C3D32F90000100ABCDEF /* AbstractGamepadOverlayWidgetView.swift */, 1817B61D2E6FD04D000D938D /* SafeTimer.swift */, ); path = Input; @@ -962,9 +966,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 2A039E552F72892B00546768 /* VoidLinkSwiftUI */ = { + 2A039E552F72892B00546768 /* VoidLinkPreview */ = { isa = PBXNativeTarget; - buildConfigurationList = 2A039E5E2F72892C00546768 /* Build configuration list for PBXNativeTarget "VoidLinkSwiftUI" */; + buildConfigurationList = 2A039E5E2F72892C00546768 /* Build configuration list for PBXNativeTarget "VoidLinkPreview" */; buildPhases = ( 2A039E522F72892B00546768 /* Sources */, 2A039E532F72892B00546768 /* Frameworks */, @@ -977,11 +981,11 @@ fileSystemSynchronizedGroups = ( 2A039E572F72892B00546768 /* VoidLinkSwiftUI */, ); - name = VoidLinkSwiftUI; + name = VoidLinkPreview; packageProductDependencies = ( ); productName = VoidLinkSwiftUI; - productReference = 2A039E562F72892B00546768 /* VoidLinkSwiftUI.app */; + productReference = 2A039E562F72892B00546768 /* VoidLinkPreview.app */; productType = "com.apple.product-type.application"; }; FB290CED19B2C406004C83CF /* VoidLink */ = { @@ -1051,7 +1055,7 @@ projectRoot = ""; targets = ( FB290CED19B2C406004C83CF /* VoidLink */, - 2A039E552F72892B00546768 /* VoidLinkSwiftUI */, + 2A039E552F72892B00546768 /* VoidLinkPreview */, ); }; /* End PBXProject section */ @@ -1155,6 +1159,7 @@ 1850C82A2E667E4000845308 /* MicHandler.swift in Sources */, FBD3495319FF36FB002D2A60 /* SWRevealViewController.m in Sources */, 84203F232C5F24C00032EEED /* OnScreenWidgetView.swift in Sources */, + A1B2C3D42F90000100ABCDEF /* AbstractGamepadOverlayWidgetView.swift in Sources */, 2AC1E3832F507D80004F76C0 /* GenericUtils.swift in Sources */, 97E21BB52E2186A3000D5DCA /* imgui_impl_metal.mm in Sources */, 9777AAF32E2CF7A400D7FBAF /* Shaders-Linear.metal in Sources */, @@ -1612,7 +1617,7 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 2A039E5E2F72892C00546768 /* Build configuration list for PBXNativeTarget "VoidLinkSwiftUI" */ = { + 2A039E5E2F72892C00546768 /* Build configuration list for PBXNativeTarget "VoidLinkPreview" */ = { isa = XCConfigurationList; buildConfigurations = ( 2A039E5F2F72892C00546768 /* Debug */, diff --git a/VoidLink/Input/AbstractGamepadOverlayWidgetView.swift b/VoidLink/Input/AbstractGamepadOverlayWidgetView.swift new file mode 100644 index 00000000..f9a8bc95 --- /dev/null +++ b/VoidLink/Input/AbstractGamepadOverlayWidgetView.swift @@ -0,0 +1,402 @@ +import UIKit +import SwiftUI + +private enum AbstractGamepadOverlayPersistence { + private static let defaults = UserDefaults.standard + + private enum Key { + static let overlayCenterXRatio = "AbstractGamepadOverlay.overlay.centerXRatio" + static let overlayCenterYRatio = "AbstractGamepadOverlay.overlay.centerYRatio" + static let overlayWidth = "AbstractGamepadOverlay.overlay.width" + } + + struct PersistedState { + let centerRatio: CGPoint + let size: CGSize + } + + static func loadOverlayState() -> PersistedState? { + loadState( + centerXKey: Key.overlayCenterXRatio, + centerYKey: Key.overlayCenterYRatio, + widthKey: Key.overlayWidth + ) + } + + static func saveOverlayState(centerRatio: CGPoint, size: CGSize) { + saveState( + centerRatio: centerRatio, + size: size, + centerXKey: Key.overlayCenterXRatio, + centerYKey: Key.overlayCenterYRatio, + widthKey: Key.overlayWidth + ) + } + + static func centerRatio(for view: UIView, in superview: UIView) -> CGPoint { + let safeWidth = max(superview.bounds.width, 1) + let safeHeight = max(superview.bounds.height, 1) + return CGPoint(x: view.center.x / safeWidth, y: view.center.y / safeHeight) + } + + static func center(in superview: UIView, from ratio: CGPoint) -> CGPoint { + CGPoint( + x: min(max(ratio.x, 0), 1) * superview.bounds.width, + y: min(max(ratio.y, 0), 1) * superview.bounds.height + ) + } + + private static func loadState( + centerXKey: String, + centerYKey: String, + widthKey: String + ) -> PersistedState? { + guard defaults.object(forKey: centerXKey) != nil, + defaults.object(forKey: centerYKey) != nil, + defaults.object(forKey: widthKey) != nil else { + return nil + } + + let width = defaults.double(forKey: widthKey) + guard width > 0 else { return nil } + + return PersistedState( + centerRatio: CGPoint( + x: defaults.double(forKey: centerXKey), + y: defaults.double(forKey: centerYKey) + ), + size: CGSize(width: width, height: 0) + ) + } + + private static func saveState( + centerRatio: CGPoint, + size: CGSize, + centerXKey: String, + centerYKey: String, + widthKey: String + ) { + defaults.set(centerRatio.x, forKey: centerXKey) + defaults.set(centerRatio.y, forKey: centerYKey) + defaults.set(size.width, forKey: widthKey) + } +} + +@available(iOS 13.0, *) +private struct AbstractGamepadOverlayBridgeView: View { + let usesPlayStationFaceButtons: Bool + private static let overlayClusterGapAdjustmentRatio: CGFloat = 0.13 + private static let overlayUpperPrimaryClusterVerticalAdjustmentRatio: CGFloat = -0.05 + private static let overlayCenterCompressionRatio: CGFloat = 0 + private static let overlayPanelHorizontalInsetRatio: CGFloat = 0.083 + private static let overlayTriggerStubSpacingAdjustmentRatio: CGFloat = 0.17 + private static let overlayRightShoulderHorizontalOffsetRatio: CGFloat = 0.006 + private static let overlayThumbPurpleStrength: CGFloat = 1.15 + private static let overlayTriggerPurpleStrength: CGFloat = 2.0 + @ObservedObject var stateCenter = GamepadOverlayStateCenter.shared + + var body: some View { + AbstractGamepadView( + gamepadType: usesPlayStationFaceButtons ? GamepadType.ps : GamepadType.xbox, + metricsProfile: GamepadMetricsProfile.overlay, + clusterGapAdjustmentRatio: Self.overlayClusterGapAdjustmentRatio, + upperPrimaryClusterVerticalAdjustmentRatio: Self.overlayUpperPrimaryClusterVerticalAdjustmentRatio, + centerCompressionRatio: Self.overlayCenterCompressionRatio, + panelHorizontalInsetRatio: Self.overlayPanelHorizontalInsetRatio, + triggerStubSpacingAdjustmentRatio: Self.overlayTriggerStubSpacingAdjustmentRatio, + rightShoulderHorizontalOffsetRatio: Self.overlayRightShoulderHorizontalOffsetRatio, + thumbPurpleStrength: Self.overlayThumbPurpleStrength, + triggerPurpleStrength: Self.overlayTriggerPurpleStrength, + liveSnapshot: stateCenter.snapshot + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .allowsHitTesting(false) + .background(Color.clear) + } +} + +@available(iOS 13.0, *) +@objc +final class AbstractGamepadOverlayView: UIView { + private enum OverlayMaskMetrics { + static let widthRatio: CGFloat = 0.85 + static let heightRatio: CGFloat = 1.03 + static let cornerRadiusRatio: CGFloat = 0.326 + static let verticalOffsetRatio: CGFloat = -0.006 + + static let topRectWidthRatio: CGFloat = 0.6 + static let topRectHeightRatio: CGFloat = 0.15 + static let topRectCornerRadiusRatio: CGFloat = 0.22 + static let topRectVerticalOffsetRatio: CGFloat = -0.5 + } + + private var hostingControllerBox: AnyObject? + private weak var hostedView: UIView? + private let visualMaskLayer = CAShapeLayer() + private let usesPlayStationFaceButtons: Bool + private var pinchBaseTransform: CGAffineTransform = .identity + private var panStartCenter: CGPoint = .zero + private var activeGestureCount = 0 + private var didRestorePersistedState = false + private let minScale: CGFloat = 0.1 + private let maxScale: CGFloat = 2.4 + private let doubleTapWidths: [CGFloat] = [130, 200] + + @objc init(frame: CGRect, usesPlayStationFaceButtons: Bool) { + self.usesPlayStationFaceButtons = usesPlayStationFaceButtons + super.init(frame: frame) + setupHostedViewIfNeeded() + } + + override init(frame: CGRect) { + self.usesPlayStationFaceButtons = false + super.init(frame: frame) + setupHostedViewIfNeeded() + } + + required init?(coder: NSCoder) { + self.usesPlayStationFaceButtons = false + super.init(coder: coder) + setupHostedViewIfNeeded() + } + + private func setupHostedViewIfNeeded() { + guard hostedView == nil else { return } + + let hostingController = UIHostingController(rootView: AbstractGamepadOverlayBridgeView(usesPlayStationFaceButtons: usesPlayStationFaceButtons)) + hostingController.view.backgroundColor = .clear + hostingController.view.isUserInteractionEnabled = false + hostingController.view.translatesAutoresizingMaskIntoConstraints = true + hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + hostingController.view.frame = bounds + + hostingControllerBox = hostingController + hostedView = hostingController.view + backgroundColor = .clear + clipsToBounds = false + isMultipleTouchEnabled = true + layer.mask = visualMaskLayer + + addSubview(hostingController.view) + installGesturesIfNeeded() + } + + override func layoutSubviews() { + super.layoutSubviews() + hostedView?.frame = bounds + updateVisualMask() + restorePersistedStateIfNeeded() + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + didRestorePersistedState = false + restorePersistedStateIfNeeded() + } + + private func installGesturesIfNeeded() { + guard gestureRecognizers?.isEmpty != false else { return } + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + pan.minimumNumberOfTouches = 1 + pan.maximumNumberOfTouches = 1 + pan.cancelsTouchesInView = true + let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:))) + pinch.cancelsTouchesInView = true + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) + doubleTap.numberOfTapsRequired = 2 + doubleTap.cancelsTouchesInView = true + pan.delegate = self + pinch.delegate = self + doubleTap.delegate = self + pan.require(toFail: doubleTap) + addGestureRecognizer(pan) + addGestureRecognizer(pinch) + addGestureRecognizer(doubleTap) + } + + @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { + guard let superview else { return } + switch recognizer.state { + case .began: + presentFirstTouchTipIfNeeded() + beginGestureSession() + panStartCenter = center + case .changed, .ended: + let translation = recognizer.translation(in: superview) + center = CGPoint(x: panStartCenter.x + translation.x, y: panStartCenter.y + translation.y) + if recognizer.state == .ended { + endGestureSession() + } + case .cancelled, .failed: + endGestureSession() + default: + break + } + } + + @objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) { + switch recognizer.state { + case .began: + presentFirstTouchTipIfNeeded() + beginGestureSession() + pinchBaseTransform = transform + case .changed, .ended: + let baseScale = currentScale(from: pinchBaseTransform) + let nextScale = max(minScale, min(maxScale, baseScale * recognizer.scale)) + transform = CGAffineTransform(scaleX: nextScale, y: nextScale) + if recognizer.state == .ended { + endGestureSession() + } + case .cancelled, .failed: + endGestureSession() + default: + break + } + } + + @objc private func handleDoubleTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.state == .ended else { return } + presentFirstTouchTipIfNeeded() + applyScale(forTargetWidth: nextDoubleTapWidth()) + persistState() + } + + private func currentScale(from transform: CGAffineTransform) -> CGFloat { + sqrt((transform.a * transform.a) + (transform.c * transform.c)) + } + + private func updateVisualMask() { + guard bounds.width > 1, bounds.height > 1 else { + visualMaskLayer.path = nil + return + } + + let maskWidth = bounds.width * OverlayMaskMetrics.widthRatio + let maskHeight = bounds.height * OverlayMaskMetrics.heightRatio + let maskRect = CGRect( + x: (bounds.width - maskWidth) * 0.5, + y: (bounds.height - maskHeight) * 0.5 + bounds.height * OverlayMaskMetrics.verticalOffsetRatio, + width: maskWidth, + height: maskHeight + ) + let maskCornerRadius = min(maskRect.width, maskRect.height) * OverlayMaskMetrics.cornerRadiusRatio + let topRectWidth = bounds.width * OverlayMaskMetrics.topRectWidthRatio + let topRectHeight = bounds.height * OverlayMaskMetrics.topRectHeightRatio + let topRect = CGRect( + x: (bounds.width - topRectWidth) * 0.5, + y: (bounds.height - topRectHeight) * 0.5 + bounds.height * OverlayMaskMetrics.topRectVerticalOffsetRatio, + width: topRectWidth, + height: topRectHeight + ) + let topRectCornerRadius = min(topRect.width, topRect.height) * OverlayMaskMetrics.topRectCornerRadiusRatio + + visualMaskLayer.frame = bounds + let path = UIBezierPath( + roundedRect: maskRect, + byRoundingCorners: .allCorners, + cornerRadii: CGSize(width: maskCornerRadius, height: maskCornerRadius) + ) + path.append( + UIBezierPath( + roundedRect: topRect, + byRoundingCorners: .allCorners, + cornerRadii: CGSize(width: topRectCornerRadius, height: topRectCornerRadius) + ) + ) + visualMaskLayer.path = path.cgPath + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds.contains(point) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) {} + override func touchesMoved(_ touches: Set, with event: UIEvent?) {} + override func touchesEnded(_ touches: Set, with event: UIEvent?) {} + override func touchesCancelled(_ touches: Set, with event: UIEvent?) {} + + private func restorePersistedStateIfNeeded() { + guard !didRestorePersistedState, + let superview, + bounds.width > 0, + bounds.height > 0, + superview.bounds.width > 0, + superview.bounds.height > 0 else { return } + defer { didRestorePersistedState = true } + + guard let state = AbstractGamepadOverlayPersistence.loadOverlayState() else { return } + let restoredScale = max(minScale, min(maxScale, state.size.width / bounds.width)) + transform = CGAffineTransform(scaleX: restoredScale, y: restoredScale) + center = AbstractGamepadOverlayPersistence.center(in: superview, from: state.centerRatio) + } + + private func beginGestureSession() { + activeGestureCount += 1 + } + + private func presentFirstTouchTipIfNeeded() { + guard GenericUtils.isFirstLaunchGamepadOverlayFeature() else { return } + guard let presentingViewController = nearestViewController() else { return } + + let alertController = UIAlertController( + title: GenericUtils.gamepadOverlayFeatureTipTitle(), + message: GenericUtils.gamepadOverlayFeatureTipMessage(), + preferredStyle: .alert + ) + alertController.addAction( + UIAlertAction( + title: GenericUtils.gamepadOverlayFeatureTipButtonTitle(), + style: .default + ) + ) + presentingViewController.present(alertController, animated: true) + } + + private func nearestViewController() -> UIViewController? { + var responder: UIResponder? = self + while let current = responder { + if let viewController = current as? UIViewController { + return viewController + } + responder = current.next + } + return nil + } + + private func endGestureSession() { + activeGestureCount = max(0, activeGestureCount - 1) + guard activeGestureCount == 0 else { return } + persistState() + } + + private func persistState() { + guard let superview, + superview.bounds.width > 0, + superview.bounds.height > 0 else { return } + AbstractGamepadOverlayPersistence.saveOverlayState( + centerRatio: AbstractGamepadOverlayPersistence.centerRatio(for: self, in: superview), + size: frame.size + ) + } + + private func nextDoubleTapWidth() -> CGFloat { + let currentWidth = frame.width + return abs(currentWidth - doubleTapWidths[0]) <= abs(currentWidth - doubleTapWidths[1]) + ? doubleTapWidths[1] + : doubleTapWidths[0] + } + + private func applyScale(forTargetWidth targetWidth: CGFloat) { + guard bounds.width > 0 else { return } + let targetScale = max(minScale, min(maxScale, targetWidth / bounds.width)) + transform = CGAffineTransform(scaleX: targetScale, y: targetScale) + } +} + +@available(iOS 13.0, *) +extension AbstractGamepadOverlayView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + (gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPinchGestureRecognizer) + || (gestureRecognizer is UIPinchGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer) + } +} diff --git a/VoidLink/Input/CommandManager.swift b/VoidLink/Input/CommandManager.swift index 92d9a318..8582aa5d 100644 --- a/VoidLink/Input/CommandManager.swift +++ b/VoidLink/Input/CommandManager.swift @@ -133,6 +133,7 @@ import UIKit "SETTINGS", "TOOLBOX", "WIDGETTOOL", + "ABSGAMEPAD", "WIDGETPROFILES", "PROFILES", "PICKPROFILE", @@ -146,6 +147,7 @@ import UIKit "ERASER", "NOSINGLETOUCH", "DISABLETOUCH", + "GAMEPADOVERLAY", ] @objc public static let pencilProButtonCmds: [String] = [ "PENCILHOVER", diff --git a/VoidLink/Input/ControllerSupport.m b/VoidLink/Input/ControllerSupport.m index 687cd342..45da91b2 100644 --- a/VoidLink/Input/ControllerSupport.m +++ b/VoidLink/Input/ControllerSupport.m @@ -1139,148 +1139,151 @@ double rc_expo(double x, double expo) { [ControllerUtil listenWithController:controller swapABXY:self->_swapABXYButtons handler:^(NSDictionary * buttonDict, GCExtendedGamepad * gamepad, GCControllerElement * element) { - 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){ - if(button.pressed){ - if(buttonFlagId.intValue == self->_controllerMouseSwitch){ - self->_mouseSwitchButtonPressed = true; - self->mouseSwitchDownTimestamp = CACurrentMediaTime(); + 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){ + if(button.pressed){ + if(buttonFlagId.intValue == self->_controllerMouseSwitch){ + self->_mouseSwitchButtonPressed = true; + 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{ + 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 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 switchGyroOnOffByControllerButton]; + 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 switchGyroOnOffByControllerButton]; + } + if (buttonFlagId.intValue == self->_controllerGyroSwitchHold + && !self->_controllerGyroSwitchHoldPressed) { + self->_controllerGyroSwitchHoldPressed = true; + + self->_gyroEnabledFlag = !self->_reverseHoldButton; + [self switchGyroOnOffByControllerButton]; + } } - if (buttonFlagId.intValue == self->_controllerGyroSwitchHold - && !self->_controllerGyroSwitchHoldPressed) { - self->_controllerGyroSwitchHoldPressed = true; - - self->_gyroEnabledFlag = !self->_reverseHoldButton; - [self switchGyroOnOffByControllerButton]; + 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 switchGyroOnOffByControllerButton]; + } } } - else{ - if (buttonFlagId.intValue == self->_controllerGyroSwitchToggle - && self->_controllerGyroSwitchTogglePressed) { - self->_controllerGyroSwitchTogglePressed = false; + + 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); } - if (buttonFlagId.intValue == self->_controllerGyroSwitchHold - && self->_controllerGyroSwitchHoldPressed) { - self->_controllerGyroSwitchHoldPressed = false; - - self->_gyroEnabledFlag = self->_reverseHoldButton; - [self switchGyroOnOffByControllerButton]; - } - } - } - - 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); } - 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]; + 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; } - if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo]) { - [self handleControllerTouchpad:voidController - touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo] - index:1]; + 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]; + } + } + }); }]; /* - controller.extendedGamepad.valueChangedHandler = ^(GCExtendedGamepad *gamepad, GCControllerElement *element) { + controller.extendedGamepad.valueChangedHandler = + ^(GCExtendedGamepad *gamepad, GCControllerElement *element) { VoidController* voidController = [self->_voidControllers objectForKey:[NSNumber numberWithInteger:gamepad.controller.playerIndex]]; short leftStickX, leftStickY; short rightStickX, rightStickY; diff --git a/VoidLink/Input/ControllerUtil.swift b/VoidLink/Input/ControllerUtil.swift index d028759a..4d3f57ff 100644 --- a/VoidLink/Input/ControllerUtil.swift +++ b/VoidLink/Input/ControllerUtil.swift @@ -8,6 +8,9 @@ import GameController +import Combine +import CoreGraphics +import Foundation @objc enum ControllerButton: Int { // Face buttons @@ -99,6 +102,11 @@ import GameController } } handler(buttonDict, gamepad, element) + if #available(iOS 13.0, *) { + if controller.playerIndex == .index1 { + GamepadOverlayStateCenter.shared.publish(snapshot: GamepadOverlaySnapshot(gamepad: gamepad)) + } + } } } @@ -247,3 +255,70 @@ import GameController return CGVector(dx: circulatedX, dy: circulatedY) } } + +@available(iOS 13.0, *) +struct GamepadOverlaySnapshot: Equatable { + var pressedButtons: Set = [] + var dpadHighlight: Int = 0 + var leftStick: CGPoint = .zero + var rightStick: CGPoint = .zero + var leftTrigger: CGFloat = 0 + var rightTrigger: CGFloat = 0 + + static let idle = GamepadOverlaySnapshot() + + init() {} + + init(gamepad: GCExtendedGamepad) { + var pressedButtons = Set() + + if gamepad.buttonA.isPressed { pressedButtons.insert(.a) } + if gamepad.buttonB.isPressed { pressedButtons.insert(.b) } + if gamepad.buttonX.isPressed { pressedButtons.insert(.x) } + 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 #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) } + + var dpadHighlight = 0 + if gamepad.dpad.up.isPressed { dpadHighlight |= DPadHighlight.up.rawValue } + if gamepad.dpad.down.isPressed { dpadHighlight |= DPadHighlight.down.rawValue } + if gamepad.dpad.left.isPressed { dpadHighlight |= DPadHighlight.left.rawValue } + if gamepad.dpad.right.isPressed { dpadHighlight |= DPadHighlight.right.rawValue } + + self.pressedButtons = pressedButtons + self.dpadHighlight = dpadHighlight + self.leftStick = CGPoint( + x: CGFloat(max(-1, min(1, gamepad.leftThumbstick.xAxis.value))), + y: CGFloat(max(-1, min(1, gamepad.leftThumbstick.yAxis.value))) + ) + self.rightStick = CGPoint( + x: CGFloat(max(-1, min(1, gamepad.rightThumbstick.xAxis.value))), + y: CGFloat(max(-1, min(1, gamepad.rightThumbstick.yAxis.value))) + ) + self.leftTrigger = CGFloat(max(0, min(1, gamepad.leftTrigger.value))) + self.rightTrigger = CGFloat(max(0, min(1, gamepad.rightTrigger.value))) + } +} + +@available(iOS 13.0, *) +@objc(GamepadOverlayStateCenter) +final class GamepadOverlayStateCenter: NSObject, ObservableObject { + static let shared = GamepadOverlayStateCenter() + + @Published private(set) var snapshot: GamepadOverlaySnapshot = .idle + + func publish(snapshot: GamepadOverlaySnapshot) { + DispatchQueue.main.async { + self.snapshot = snapshot + } + } + + @objc static func clearSharedState() { + shared.publish(snapshot: .idle) + } +} diff --git a/VoidLink/Input/CustomOSC/OSCProfile.h b/VoidLink/Input/CustomOSC/OSCProfile.h index 9cb58e35..e5a82b21 100644 --- a/VoidLink/Input/CustomOSC/OSCProfile.h +++ b/VoidLink/Input/CustomOSC/OSCProfile.h @@ -58,6 +58,9 @@ typedef NS_ENUM(NSInteger, PencilHoverMode) { @property (nonatomic, assign) bool reverseGyroHoldButton; @property (nonatomic, assign) int controllerGyroSwitchHold; @property (nonatomic, assign) int controllerGyroSwitchToggle; + +@property (nonatomic, assign) bool gamepadOverlayEnabled; + @property NSArray *pressureCurvePoints; // @property NSArray *initialTouchPressureCurvePoints; @property (nonatomic, assign) int phase1StrokeSampleIndexEnd; diff --git a/VoidLink/Input/CustomOSC/OSCProfile.m b/VoidLink/Input/CustomOSC/OSCProfile.m index 71e2cc11..a57fbffd 100644 --- a/VoidLink/Input/CustomOSC/OSCProfile.m +++ b/VoidLink/Input/CustomOSC/OSCProfile.m @@ -51,6 +51,8 @@ [encoder encodeBool:self.reverseGyroHoldButton forKey:@"reverseGyroHoldButton"]; [encoder encodeInt:self.controllerGyroSwitchHold forKey:@"controllerGyroSwitchHold"]; [encoder encodeInt:self.controllerGyroSwitchToggle forKey:@"controllerGyroSwitchToggle"]; + [encoder encodeBool:self.gamepadOverlayEnabled forKey:@"gamepadOverlayEnabled"]; + [encoder encodeObject:self.pressureCurvePoints forKey:@"pressureCurvePoints"]; // [encoder encodeObject:self.initialTouchPressureCurvePoints forKey:@"initialTouchPressureCurvePoints"]; [encoder encodeInt:self.phase1StrokeSampleIndexEnd forKey:@"phase1StrokeSampleIndexEnd"]; @@ -112,7 +114,8 @@ 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.gamepadOverlayEnabled = [decoder containsValueForKey:@"gamepadOverlayEnabled"] ? [decoder decodeBoolForKey:@"gamepadOverlayEnabled"] : true; + self.pressureCurvePoints = [decoder containsValueForKey:@"pressureCurvePoints"] ? [decoder decodeObjectOfClasses: @@ -209,6 +212,7 @@ copy.reverseGyroHoldButton = self.reverseGyroHoldButton; copy.controllerGyroSwitchHold = self.controllerGyroSwitchHold; copy.controllerGyroSwitchToggle = self.controllerGyroSwitchToggle; + copy.gamepadOverlayEnabled = self.gamepadOverlayEnabled; copy.pressureCurvePoints = [[NSMutableArray alloc] initWithArray:self.pressureCurvePoints copyItems:YES]; // copy.initialTouchPressureCurvePoints = [[NSMutableArray alloc] initWithArray:self.initialTouchPressureCurvePoints copyItems:YES]; copy.phase1StrokeSampleIndexEnd = self.phase1StrokeSampleIndexEnd; diff --git a/VoidLink/Input/OnScreenWidgetView.swift b/VoidLink/Input/OnScreenWidgetView.swift index f76d5a81..88463b2a 100644 --- a/VoidLink/Input/OnScreenWidgetView.swift +++ b/VoidLink/Input/OnScreenWidgetView.swift @@ -10,6 +10,11 @@ import UIKit import SVGKit @objc class OnScreenWidgetView: UIView { + @objc(widgetWithCmdString:buttonLabel:shape:profile:) + class func widget(cmdString: String, buttonLabel: String, shape: String, profile: OSCProfile) -> OnScreenWidgetView { + return OnScreenWidgetView(cmdString: cmdString, buttonLabel: buttonLabel, shape: shape, profile: profile) + } + @objc public static var mapping: [Int16:OnScreenWidgetView] = [:] @objc public static var isRestoring: Bool = false @objc public static func set(widget:OnScreenWidgetView, for key:Int16) { @@ -55,6 +60,7 @@ import SVGKit func replaceEraser(shortcut:String) func presentPressureCurveVC() func toggleTouch(disabled:Bool) + func toggleGamepadOverlay(overlayEnabled:Bool) } @objc enum WidgetTypeEnum: UInt8 { @@ -77,7 +83,7 @@ import SVGKit @objc public var cmdString: String @objc public var sequence: Int16 = -1 private var buttonString: String = "" - @objc private(set) var functionalButtonString: String = "" + @objc var functionalButtonString: String = "" public var motionControlButtonString: String = "" @objc public var touchPadString: String = "" // super combo key string set @@ -348,7 +354,7 @@ import SVGKit else { self.widgetType = WidgetTypeEnum.button // legacy combo button connected by "+" } - + print("widgetType: \(self.widgetType)") print("touchPadString: \(self.touchPadString)") for comboButtonString in comboButtonStrings { @@ -498,7 +504,8 @@ import SVGKit || (self.widgetType == WidgetTypeEnum.button && (buttonMode == .slideAndHold || buttonMode == .slideToToggle)))*/ self.hasTrackPoint = true - self.hasNonEditableLabel = self.cmdString == "DISABLETOUCH" + self.hasNonEditableLabel = (self.cmdString == "DISABLETOUCH" + || self.cmdString == "GAMEPADOVERLAY") } // ====================================================================================================== @@ -801,13 +808,20 @@ import SVGKit return false } - private func setupAtrributedText(){ + @objc func setupAtrributedText(){ var text = self.widgetLabel.contains("#") ? "\(self.widgetLabel.split(separator: "#").first ?? "")" : SwiftLocalizationHelper.localizedString(forKey: self.widgetLabel) if self.hasNonEditableLabel { - if cmdString == "DISABLETOUCH" { + + switch cmdString { + case "DISABLETOUCH": self.nonEditableWidgetLabel = SwiftLocalizationHelper.localizedString(forKey: touchDisabledFLag ? "=EnableTouch" : "=DisableTouch" ) + case "GAMEPADOVERLAY": + self.nonEditableWidgetLabel = SwiftLocalizationHelper.localizedString(forKey: gamepadOverlayFLag ? "=GamepadOverlayOn" : "=GamepadOverlayOff" ) + default: + nonEditableWidgetLabel = "" } + text = self.nonEditableWidgetLabel } @@ -850,6 +864,7 @@ import SVGKit self.setupAtrributedText() label.translatesAutoresizingMaskIntoConstraints = false // enable auto alignment for the label + label.isHidden = shouldHideStandardLabel() self.translatesAutoresizingMaskIntoConstraints = true // this is mandatory to prevent unexpected key view location change @@ -901,6 +916,7 @@ import SVGKit // self.layer.shadowOpacity = 0.5 addSubview(label) + _ = installCustomContentIfNeeded() if(OnScreenWidgetView.editMode) {self.changeAndActivateContraints()} @@ -924,6 +940,19 @@ import SVGKit QUICK_TAP_TIME_INTERVAL = 0.15 } } + + @objc func shouldHideStandardLabel() -> Bool { + false + } + + @discardableResult + @objc func installCustomContentIfNeeded() -> Bool { + false + } + + @objc func handleQuickDoubleTapAction() -> Bool { + false + } @objc public func setupL3R3Indicator() { CATransaction.begin() @@ -1820,6 +1849,10 @@ import SVGKit touchTapTimeInterval = currentTime - touchTapTimeStamp touchTapTimeStamp = currentTime quickDoubleTapDetected = touchTapTimeInterval < QUICK_TAP_TIME_INTERVAL + if quickDoubleTapDetected, handleQuickDoubleTapAction() { + quickDoubleTapDetected = false + return + } if quickDoubleTapDetected, self.isFolder, self.buttonMode == .slideAndHold { self.temporarilyMovable = true } @@ -2523,6 +2556,8 @@ import SVGKit } case "DISABLETOUCH": self.handleTouchDisableButtonUp() + case "GAMEPADOVERLAY": + self.gamepadOverlayButtonUp() default: break } @@ -2534,6 +2569,14 @@ import SVGKit self.setupAtrributedText() self.functionalButtonDelegate?.toggleTouch(disabled: touchDisabledFLag) } + + @objc var gamepadOverlayFLag:Bool = false + private func gamepadOverlayButtonUp(){ + self.relocatedDuringStreaming = true + gamepadOverlayFLag = !gamepadOverlayFLag + self.setupAtrributedText() + self.functionalButtonDelegate?.toggleGamepadOverlay(overlayEnabled: gamepadOverlayFLag) + } private func temporaryDisableFolderButtonAnimation(){ OnScreenWidgetView.enableFolderAnimation = false @@ -3204,4 +3247,3 @@ import SVGKit print("onScreenWidgetView deinit \(CACurrentMediaTime())") } } - diff --git a/VoidLink/Input/StreamView.m b/VoidLink/Input/StreamView.m index 75bf128f..9df212e5 100644 --- a/VoidLink/Input/StreamView.m +++ b/VoidLink/Input/StreamView.m @@ -31,6 +31,7 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5; + /* Stream Video has been moved out of this class to _renderView in StreamFrameViewController. */ @@ -597,6 +598,7 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5; newProfile.unfoldedExclusiveFolderSequence = OnScreenWidgetView.unfoldedExclusiveFolderSequence; newProfile.postExclusiveUnfoldedSequences = OnScreenWidgetView.postExclusiveUnfoldedSequences; + newProfile.gamepadOverlayEnabled = ((StreamFrameViewController*)_streamFrameVC).virtualGamepadOverlay != nil; [oscProfileMan replaceSelectedProfileWith:newProfile overwriteDefault:YES]; } @@ -612,6 +614,10 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5; // get streamFrameVC if(!self->_streamFrameVC) self->_streamFrameVC = [self parentViewController]; + if (@available(iOS 13.0, *)) { + if(oscProfile.gamepadOverlayEnabled) [(StreamFrameViewController* )self->_streamFrameVC loadAbstractGamepadOverlayIfNeeded]; + } + // bool customOscEnabled = [self isOscEnabled] && settings.onscreenControls.intValue == OnScreenControlsLevelCustom; if(![self isOnScreenWidgetEnabled]) return; @@ -629,7 +635,7 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5; NSData* buttonStateEncoded = oscProfile.buttonStatesEncoded[i]; OnScreenButtonState* buttonState = [self->oscProfileMan unarchiveButtonStateEncoded:buttonStateEncoded]; if(buttonState.widgetType == CustomOnScreenWidget){ - OnScreenWidgetView* widgetView = [[OnScreenWidgetView alloc] initWithCmdString:buttonState.name buttonLabel:buttonState.alias shape:buttonState.widgetShape profile:oscProfile]; //reconstruct widgetView + OnScreenWidgetView* widgetView = [OnScreenWidgetView widgetWithCmdString:buttonState.name buttonLabel:buttonState.alias shape:buttonState.widgetShape profile:oscProfile]; //reconstruct widgetView widgetView.functionalButtonDelegate = (id)self->_streamFrameVC; widgetView.motionHandler = motionHandler; @@ -682,6 +688,9 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5; [widgetView setupAutoTapTimer]; [widgetView setupInertialScrollerWithFps:self->settings.framerate.intValue]; + widgetView.gamepadOverlayFLag = oscProfile.gamepadOverlayEnabled; + [widgetView setupAtrributedText]; + if(sequenceGenerated){ // NSLog(@"widgetView.sequence %d %f", widgetView.sequence, CACurrentMediaTime()); buttonState.sequence = widgetView.sequence; diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index e11df41e..11d4fc34 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -675,6 +675,64 @@ } } }, + "=GamepadOverlayOff" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "🟢 🎮 overlay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "🟢 🎮 叠加" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "🟢 🎮 疊加" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "🟢 🎮 疊加" + } + } + } + }, + "=GamepadOverlayOn" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "🚫 🎮 overlay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "🚫 🎮 叠加" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "🚫 🎮 疊加" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "🚫 🎮 疊加" + } + } + } + }, "=gyroRsvpad" : { "extractionState" : "manual", "localizations" : { @@ -1226,21 +1284,51 @@ } } }, - "▴" : { - "comment" : "A symbol representing the \"up\" direction on a directional pad.", - "isCommentAutoGenerated" : true + "🎮 overlay" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "🎮 叠加" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "🎮 疊加" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "🎮 疊加" + } + } + } }, - "▸" : { - "comment" : "A directional arrow pointing to the right.", - "isCommentAutoGenerated" : true - }, - "▾" : { - "comment" : "A symbol resembling a downward arrow.", - "isCommentAutoGenerated" : true - }, - "◂" : { - "comment" : "A directional arrow pointing to the left.", - "isCommentAutoGenerated" : true + "🎮 overlay switch" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "🎮 叠加 开关" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "🎮 疊加 開關" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "🎮 疊加 開關" + } + } + } }, "A button with touchpad functionality will be created" : { "extractionState" : "manual", @@ -4968,6 +5056,35 @@ } } }, + "gamepadOverlayFeatureTip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hello! I'm the VoidLink gamepad live feedback overlay. You can use the \"🎮\" button in the widget menu to turn me on or off.\nI can be resized by double-tap or two-finger pinch, and moved by dragging directly." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你好!我是 VoidLink 手柄实时反馈叠加层。控件菜单中的 \"🎮\" 按键可以打开或关闭我。\n我可以由双击或双指捏合改变大小、直接拖动改变位置。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你好!我是 VoidLink 手把即時反饋疊加層。控制選單中的「🎮」按鍵可以開啟或關閉我。\n我可以透過雙擊或雙指捏合改變大小,也可以直接拖曳改變位置。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "你好!我是 VoidLink 手把即時反饋疊加層。控制選單中的「🎮」按鍵可以開啟或關閉我。\n我可以透過雙擊或雙指捏合改變大小,也可以直接拖曳改變位置。" + } + } + } + }, "Gestures" : { "extractionState" : "manual", "localizations" : { @@ -10733,6 +10850,29 @@ } } }, + "Switch on/off a floating gamepad overlay with live input feedback" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开关一个带有实时输入反馈的虚拟手柄叠加层" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開關一個帶有即時輸入回饋的虛擬手把覆蓋層" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "開關一個帶有即時輸入回饋的虛擬手把覆蓋層" + } + } + } + }, "Tap 'OK' to set on-screen widget to 'Custom' and enable motion control." : { "extractionState" : "manual", "localizations" : { diff --git a/VoidLink/Utility/GenericUtils.swift b/VoidLink/Utility/GenericUtils.swift index 8192dd02..dd98e43b 100644 --- a/VoidLink/Utility/GenericUtils.swift +++ b/VoidLink/Utility/GenericUtils.swift @@ -41,6 +41,30 @@ import Foundation } return false } + + @objc static func isFirstLaunchGamepadOverlayFeature() -> Bool { + let key = "hasTouchedGamepadOverlayFeature20260405-1" + let defaults = UserDefaults.standard + let launchedBefore = defaults.bool(forKey: key) + + if !launchedBefore { + defaults.set(true, forKey: key) + return true + } + return false + } + + @objc static func gamepadOverlayFeatureTipTitle() -> String { + SwiftLocalizationHelper.localizedString(forKey: "Gamepad Overlay") + } + + @objc static func gamepadOverlayFeatureTipMessage() -> String { + SwiftLocalizationHelper.localizedString(forKey: "gamepadOverlayFeatureTip") + } + + @objc static func gamepadOverlayFeatureTipButtonTitle() -> String { + SwiftLocalizationHelper.localizedString(forKey: "Got it!") + } @objc static func isIPhone() -> Bool { return UIDevice.current.userInterfaceIdiom == .phone diff --git a/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.m b/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.m index ef99a1f9..f48fdb7f 100644 --- a/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.m +++ b/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.m @@ -162,7 +162,7 @@ typedef NS_ENUM(NSUInteger, DecelerationRateSliderMode) { OnScreenButtonState* buttonState = [self->profilesManager unarchiveButtonStateEncoded:buttonStateEncoded]; NSLog(@"reloadOnScreenWidgets name %@", buttonState.name); if(buttonState.widgetType == CustomOnScreenWidget){ - OnScreenWidgetView* widgetView = [[OnScreenWidgetView alloc] initWithCmdString:buttonState.name buttonLabel:buttonState.alias shape:buttonState.widgetShape profile:oscProfile]; //reconstruct widgetView + OnScreenWidgetView* widgetView = [OnScreenWidgetView widgetWithCmdString:buttonState.name buttonLabel:buttonState.alias shape:buttonState.widgetShape profile:oscProfile]; //reconstruct widgetView widgetView.sequence = buttonState.sequence == -1 ? ++sequence : buttonState.sequence; [OnScreenWidgetView setWithWidget:widgetView for:widgetView.sequence]; @@ -926,7 +926,7 @@ typedef NS_ENUM(NSUInteger, DecelerationRateSliderMode) { - (void) updateWidget:(OnScreenWidgetView* )widget byParams:(NSMutableDictionary* )widgetInitParams createNew:(bool)createNew{ if(![self isWidgetParamsValid:widgetInitParams]) return; OSCProfile* profile = [[OSCProfilesManager sharedManager:CGRectZero] getSelectedProfile]; - OnScreenWidgetView* newWidget = [[OnScreenWidgetView alloc] initWithCmdString:widgetInitParams[@"cmdString"] buttonLabel:widgetInitParams[@"buttonLabel"] shape:widgetInitParams[@"shape"] profile:profile]; //reconstruct widgetView + OnScreenWidgetView* newWidget = [OnScreenWidgetView widgetWithCmdString:widgetInitParams[@"cmdString"] buttonLabel:widgetInitParams[@"buttonLabel"] shape:widgetInitParams[@"shape"] profile:profile]; //reconstruct widgetView newWidget.sequence = widget.sequence; newWidget.revealMode = widget.revealMode; newWidget.bulkMoveEnabled = widget.bulkMoveEnabled; @@ -991,7 +991,7 @@ typedef NS_ENUM(NSUInteger, DecelerationRateSliderMode) { if(![self isWidgetParamsValid:widgetInitParams]) return; //saving & present the keyboard button: OSCProfile* profile = [[OSCProfilesManager sharedManager:CGRectZero] getSelectedProfile]; - OnScreenWidgetView* widgetView = [[OnScreenWidgetView alloc] initWithCmdString:widgetInitParams[@"cmdString"] buttonLabel:widgetInitParams[@"buttonLabel"] shape:widgetInitParams[@"shape"] profile:profile]; + OnScreenWidgetView* widgetView = [OnScreenWidgetView widgetWithCmdString:widgetInitParams[@"cmdString"] buttonLabel:widgetInitParams[@"buttonLabel"] shape:widgetInitParams[@"shape"] profile:profile]; [self.view insertSubview:widgetView belowSubview:self.widgetPanelStack]; widgetView.hidden = true; widgetView.sequence = [widgetView getAvailableSequence]; diff --git a/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.swift b/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.swift index 657b35f3..12278b89 100644 --- a/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.swift +++ b/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.swift @@ -348,7 +348,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg for case let encoded as Data in oscProfile.buttonStatesEncoded { guard let buttonState = self.profilesManager?.unarchiveButtonStateEncoded(encoded) else { continue } if buttonState.widgetType == 1 { - let widgetView = OnScreenWidgetView(cmdString: buttonState.name, buttonLabel: buttonState.alias, shape: buttonState.widgetShape, profile: oscProfile) + let widgetView = OnScreenWidgetView.widget(cmdString: buttonState.name, buttonLabel: buttonState.alias, shape: buttonState.widgetShape, profile: oscProfile) widgetView.sequence = (buttonState.sequence == -1 || folder != nil) ? { sequence += 1 return sequence @@ -1014,12 +1014,12 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg private func updateWidget(_ widget: OnScreenWidgetView, params: NSMutableDictionary, createNew: Bool) { guard isWidgetParamsValid(params) else { return } let profile = OSCProfilesManager.sharedManager(.zero).getSelectedProfile() - guard let newWidget = OnScreenWidgetView( + let newWidget = OnScreenWidgetView.widget( cmdString: params["cmdString"] as? String ?? "", buttonLabel: params["buttonLabel"] as? String ?? "", shape: params["shape"] as? String ?? "", profile: profile - ) as OnScreenWidgetView? else { return } + ) newWidget.sequence = widget.sequence newWidget.revealMode = widget.revealMode @@ -1079,7 +1079,7 @@ final class LayoutOnScreenControlsViewController: UIViewController, OnScreenWidg private func createWidgetFromParams(_ params: NSMutableDictionary) { guard isWidgetParamsValid(params) else { return } let profile = OSCProfilesManager.sharedManager(.zero).getSelectedProfile() - let widgetView = OnScreenWidgetView( + let widgetView = OnScreenWidgetView.widget( cmdString: params["cmdString"] as? String ?? "", buttonLabel: params["buttonLabel"] as? String ?? "", shape: params["shape"] as? String ?? "", diff --git a/VoidLink/ViewControllers/MainFrameViewController.m b/VoidLink/ViewControllers/MainFrameViewController.m index 005df062..7e9293be 100644 --- a/VoidLink/ViewControllers/MainFrameViewController.m +++ b/VoidLink/ViewControllers/MainFrameViewController.m @@ -80,6 +80,7 @@ id _controllerConnectObserver; id _controllerDisconnectObserver; + UIView* _debugGamepadOverlay; #if TARGET_OS_TV @@ -2495,4 +2496,3 @@ static NSMutableSet* hostList; } @end - diff --git a/VoidLink/ViewControllers/StreamFrameViewController.h b/VoidLink/ViewControllers/StreamFrameViewController.h index 774a874d..6871c98d 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.h +++ b/VoidLink/ViewControllers/StreamFrameViewController.h @@ -41,11 +41,13 @@ @property (nonatomic, strong) MetalViewController *metalViewController; @property (nonatomic, strong) ImGuiRenderer *imguiView; +@property (nonatomic, strong) UIView* virtualGamepadOverlay; - (void)updatePreferredDisplayMode:(BOOL)streamActive; - (void)setUserInteractionEnabledForStreamView:(bool)enabled; - (bool)shallDisableGyroHotSwitch; - (void)loadGameProfileConfigs:(OSCProfile* )profile; +- (void)loadAbstractGamepadOverlayIfNeeded API_AVAILABLE(ios(13.0)); @end diff --git a/VoidLink/ViewControllers/StreamFrameViewController.m b/VoidLink/ViewControllers/StreamFrameViewController.m index 4c822c4c..c213ed3d 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.m +++ b/VoidLink/ViewControllers/StreamFrameViewController.m @@ -507,7 +507,7 @@ NSLog(@"pausing..."); nil; }); - + // check to see if external screen is connected/disconnected [[NSNotificationCenter defaultCenter] addObserver: self @@ -1766,6 +1766,35 @@ dispatch_after(delayTime, dispatch_get_main_queue(), block); } +- (void)toggleGamepadOverlayWithOverlayEnabled:(BOOL)overlayEnabled API_AVAILABLE(ios(13.0)){ + if(overlayEnabled) [self loadAbstractGamepadOverlayIfNeeded]; + else { + [_virtualGamepadOverlay removeFromSuperview]; + _virtualGamepadOverlay = nil; + } +} + +- (void)loadAbstractGamepadOverlayIfNeeded API_AVAILABLE(ios(13.0)){ + if (_virtualGamepadOverlay != nil || self.view.window == nil) { + return; + } + + BOOL usesXboxFaceButtons = _settings.emulatedControllerType.intValue == LI_CTYPE_XBOX; + + CGFloat maxWidth = MIN(CGRectGetWidth(self.view.bounds) * 0.72, 620); + CGFloat width = MAX(420, maxWidth); + CGFloat height = width / 1.82; + CGRect overlayFrame = CGRectMake(0, 0, width, height); + + AbstractGamepadOverlayView *overlayView = [[AbstractGamepadOverlayView alloc] initWithFrame:overlayFrame usesPlayStationFaceButtons:!usesXboxFaceButtons]; + overlayView.center = CGPointMake(CGRectGetMidX(self.view.bounds), CGRectGetMidY(self.view.bounds)); + overlayView.userInteractionEnabled = YES; + [self.view addSubview:overlayView]; + + _virtualGamepadOverlay = overlayView; +} + + - (void)dealloc { NSLog(@"dealloc StreamFrameViewController %f", CACurrentMediaTime()); } diff --git a/VoidLinkSwiftUI/AbstractGamepadView.swift b/VoidLinkSwiftUI/AbstractGamepadView.swift index ae3bb184..77d3a7d9 100644 --- a/VoidLinkSwiftUI/AbstractGamepadView.swift +++ b/VoidLinkSwiftUI/AbstractGamepadView.swift @@ -154,9 +154,26 @@ enum GamepadWidget: Hashable { } +@available(iOS 13.0, *) +enum GamepadMetricsProfile { + case standard + case picker + case overlay +} + @available(iOS 13.0, *) struct AbstractGamepadView: View { let gamepadType: GamepadType + let metricsProfile: GamepadMetricsProfile + let clusterGapAdjustmentRatio: CGFloat + let upperPrimaryClusterVerticalAdjustmentRatio: CGFloat + let centerCompressionRatio: CGFloat + let panelHorizontalInsetRatio: CGFloat + let triggerStubSpacingAdjustmentRatio: CGFloat + let rightShoulderHorizontalOffsetRatio: CGFloat + let thumbPurpleStrength: CGFloat + let triggerPurpleStrength: CGFloat + var liveSnapshot: GamepadOverlaySnapshot? = nil var canSelectCommand: ((String) -> Bool)? = nil var isCommandSelected: ((String) -> Bool)? = nil var onCommandSelected: ((String) -> Void)? = nil @@ -168,6 +185,16 @@ struct AbstractGamepadView: View { init( gamepadType: GamepadType = .xbox, + metricsProfile: GamepadMetricsProfile = .standard, + clusterGapAdjustmentRatio: CGFloat = 0, + upperPrimaryClusterVerticalAdjustmentRatio: CGFloat = 0, + centerCompressionRatio: CGFloat = 0, + panelHorizontalInsetRatio: CGFloat = 0, + triggerStubSpacingAdjustmentRatio: CGFloat = 0, + rightShoulderHorizontalOffsetRatio: CGFloat = 0, + thumbPurpleStrength: CGFloat = 1, + triggerPurpleStrength: CGFloat = 1, + liveSnapshot: GamepadOverlaySnapshot? = nil, canSelectCommand: ((String) -> Bool)? = nil, isCommandSelected: ((String) -> Bool)? = nil, onCommandSelected: ((String) -> Void)? = nil, @@ -178,6 +205,16 @@ struct AbstractGamepadView: View { externalDeselectionToken: Int = 0 ) { self.gamepadType = gamepadType + self.metricsProfile = metricsProfile + self.clusterGapAdjustmentRatio = clusterGapAdjustmentRatio + self.upperPrimaryClusterVerticalAdjustmentRatio = upperPrimaryClusterVerticalAdjustmentRatio + self.centerCompressionRatio = centerCompressionRatio + self.panelHorizontalInsetRatio = panelHorizontalInsetRatio + self.triggerStubSpacingAdjustmentRatio = triggerStubSpacingAdjustmentRatio + self.rightShoulderHorizontalOffsetRatio = rightShoulderHorizontalOffsetRatio + self.thumbPurpleStrength = thumbPurpleStrength + self.triggerPurpleStrength = triggerPurpleStrength + self.liveSnapshot = liveSnapshot self.canSelectCommand = canSelectCommand self.isCommandSelected = isCommandSelected self.onCommandSelected = onCommandSelected @@ -207,66 +244,153 @@ struct AbstractGamepadView: View { static var tappedWidgets: [GamepadWidget] = [] static var selectedCmd: String = "" - private struct Layout { - - // MARK: - Body - - static let aspectRatio: CGFloat = 1.82 - static let bodyCornerRadiusRatio: CGFloat = 0.3 - static let bodyShadowYOffsetRatio: CGFloat = 0.035 - static let innerStrokeInsetRatio: CGFloat = 0.018 - static let radialHighlightEndRadiusRatio: CGFloat = 0.48 - static let innerContentScale: CGFloat = 1.13 - static let ds4TouchpadWidthRatio: CGFloat = 0.2 - static let ds4TouchpadHeightRatio: CGFloat = 0.135 - static let ds4TouchpadCornerRatio: CGFloat = 0.30 - - // MARK: - Top decoration - - static let topTriggerStubHorizontalPaddingRatio: CGFloat = 0.11 - static let topTriggerStubCenterYRatio: CGFloat = -0.236 - static let topTriggerStubCenterXRatio: CGFloat = 0.008 - - static let topTriggerStubWidthRatio: CGFloat = 0.1062 - static let topTriggerStubHeightRatio: CGFloat = 0.0670 - static let topTriggerStubSpacingRatio: CGFloat = 0.4336 - - static let shoulderBarWidthRatio: CGFloat = 0.24 - static let shoulderBarHeightRatio: CGFloat = 0.04 - static let shoulderBarInsetFromSideRatio: CGFloat = 0.06 - static let shoulderBarCenterYRatio: CGFloat = -0.49 - // MARK: - Component sizes - - static let leftStickSizeRatio: CGFloat = 0.17 - static let rightStickSizeRatio: CGFloat = 0.17 - static let dpadSizeRatio: CGFloat = 0.17 - static let abxySizeRatio: CGFloat = 0.19 - - /// 顶部 +/- 和右下角 Home 共用直径 - static let smallTopButtonDiameterRatio: CGFloat = 0.053 - - // MARK: - Constraint groups - - /// DPad / 右摇杆 共用水平轴 - static let lowerControlCenterYRatio: CGFloat = 0.16 - - /// DPad / 右摇杆 关于中线左右对称 - static let lowerControlHalfSpacingRatio: CGFloat = 0.16 - - /// 顶部 +/- 共用水平轴 - static let topButtonCenterYRatio: CGFloat = -0.24 - - // MARK: - Left stick / ABXY / Home - - static let leftStickCenterXRatio: CGFloat = -0.28 - static let leftStickCenterYRatio: CGFloat = -0.07 - - /// Home 的垂直轴,ABXY 与它垂直对齐 - static let homeCenterXRatio: CGFloat = 0.3 - - /// Home 的垂直偏移,允许手调 - static let homeCenterYRatio: CGFloat = 0.267 + private let stickLiveDeadzone: CGFloat = 0.08 + private struct Layout { + let aspectRatio: CGFloat + let bodyCornerRadiusRatio: CGFloat + let bodyShadowYOffsetRatio: CGFloat + let innerStrokeInsetRatio: CGFloat + let radialHighlightEndRadiusRatio: CGFloat + let innerContentScale: CGFloat + let ds4TouchpadWidthRatio: CGFloat + let ds4TouchpadHeightRatio: CGFloat + let ds4TouchpadCornerRatio: CGFloat + let topTriggerStubHorizontalPaddingRatio: CGFloat + let topTriggerStubCenterYRatio: CGFloat + let topTriggerStubCenterXRatio: CGFloat + let topTriggerStubWidthRatio: CGFloat + let topTriggerStubHeightRatio: CGFloat + let topTriggerStubSpacingRatio: CGFloat + let shoulderBarWidthRatio: CGFloat + let shoulderBarHeightRatio: CGFloat + let shoulderBarInsetFromSideRatio: CGFloat + let shoulderBarCenterYRatio: CGFloat + let leftStickSizeRatio: CGFloat + let rightStickSizeRatio: CGFloat + let dpadSizeRatio: CGFloat + let abxySizeRatio: CGFloat + let smallTopButtonDiameterRatio: CGFloat + let lowerControlCenterYRatio: CGFloat + let lowerControlHalfSpacingRatio: CGFloat + let topButtonCenterYRatio: CGFloat + let leftStickCenterXRatio: CGFloat + let leftStickCenterYRatio: CGFloat + let homeCenterXRatio: CGFloat + let homeCenterYRatio: CGFloat + + static let standard = Layout( + aspectRatio: 1.82, + bodyCornerRadiusRatio: 0.3, + bodyShadowYOffsetRatio: 0.035, + innerStrokeInsetRatio: 0.018, + radialHighlightEndRadiusRatio: 0.48, + innerContentScale: 1.13, + ds4TouchpadWidthRatio: 0.2, + ds4TouchpadHeightRatio: 0.135, + ds4TouchpadCornerRatio: 0.30, + topTriggerStubHorizontalPaddingRatio: 0.11, + topTriggerStubCenterYRatio: -0.236, + topTriggerStubCenterXRatio: 0.008, + topTriggerStubWidthRatio: 0.1062, + topTriggerStubHeightRatio: 0.0670, + topTriggerStubSpacingRatio: 0.4336, + shoulderBarWidthRatio: 0.24, + shoulderBarHeightRatio: 0.04, + shoulderBarInsetFromSideRatio: 0.15, + shoulderBarCenterYRatio: -0.49, + leftStickSizeRatio: 0.17, + rightStickSizeRatio: 0.17, + dpadSizeRatio: 0.17, + abxySizeRatio: 0.19, + smallTopButtonDiameterRatio: 0.053, + lowerControlCenterYRatio: 0.16, + lowerControlHalfSpacingRatio: 0.16, + topButtonCenterYRatio: -0.24, + leftStickCenterXRatio: -0.28, + leftStickCenterYRatio: -0.07, + homeCenterXRatio: 0.3, + homeCenterYRatio: 0.267 + ) + + static let overlay = Layout( + aspectRatio: 1.82, + bodyCornerRadiusRatio: 0.3, + bodyShadowYOffsetRatio: 0.035, + innerStrokeInsetRatio: 0.018, + radialHighlightEndRadiusRatio: 0.48, + innerContentScale: 1.11, + ds4TouchpadWidthRatio: 0.2, + ds4TouchpadHeightRatio: 0.135, + ds4TouchpadCornerRatio: 0.30, + topTriggerStubHorizontalPaddingRatio: 0.11, + topTriggerStubCenterYRatio: -0.236, + topTriggerStubCenterXRatio: 0.008, + topTriggerStubWidthRatio: 0.1062, + topTriggerStubHeightRatio: 0.0670, + topTriggerStubSpacingRatio: 0.4336, + shoulderBarWidthRatio: 0.24, + shoulderBarHeightRatio: 0.04, + shoulderBarInsetFromSideRatio: 0.06, + shoulderBarCenterYRatio: -0.49, + leftStickSizeRatio: 0.205, + rightStickSizeRatio: 0.205, + dpadSizeRatio: 0.205, + abxySizeRatio: 0.225, + smallTopButtonDiameterRatio: 0.064, + lowerControlCenterYRatio: 0.17, + lowerControlHalfSpacingRatio: 0.235, + topButtonCenterYRatio: -0.235, + leftStickCenterXRatio: -0.355, + leftStickCenterYRatio: -0.02, + homeCenterXRatio: 0.36, + homeCenterYRatio: 0.265 + ) + + static let picker = Layout( + aspectRatio: 1.82, + bodyCornerRadiusRatio: 0.3, + bodyShadowYOffsetRatio: 0.035, + innerStrokeInsetRatio: 0.018, + radialHighlightEndRadiusRatio: 0.48, + innerContentScale: 1.13, + ds4TouchpadWidthRatio: 0.2, + ds4TouchpadHeightRatio: 0.135, + ds4TouchpadCornerRatio: 0.30, + topTriggerStubHorizontalPaddingRatio: 0.11, + topTriggerStubCenterYRatio: -0.236, + topTriggerStubCenterXRatio: 0.008, + topTriggerStubWidthRatio: 0.1062, + topTriggerStubHeightRatio: 0.0670, + topTriggerStubSpacingRatio: 0.402, + shoulderBarWidthRatio: 0.24, + shoulderBarHeightRatio: 0.04, + shoulderBarInsetFromSideRatio: 0.074, + shoulderBarCenterYRatio: -0.49, + leftStickSizeRatio: 0.17, + rightStickSizeRatio: 0.17, + dpadSizeRatio: 0.17, + abxySizeRatio: 0.19, + smallTopButtonDiameterRatio: 0.053, + lowerControlCenterYRatio: 0.16, + lowerControlHalfSpacingRatio: 0.16, + topButtonCenterYRatio: -0.24, + leftStickCenterXRatio: -0.28, + leftStickCenterYRatio: -0.07, + homeCenterXRatio: 0.3, + homeCenterYRatio: 0.267 + ) + } + + private var layout: Layout { + switch metricsProfile { + case .standard: + return .standard + case .picker: + return .picker + case .overlay: + return .overlay + } } private func toggle(_ target: GamepadToggleTarget) { @@ -518,17 +642,18 @@ struct AbstractGamepadView: View { private func triggerRects( containerW: CGFloat, containerH: CGFloat, + panelW: CGFloat, w: CGFloat, h: CGFloat ) -> (left: CGRect, right: CGRect) { - let triggerWidth = w * Layout.topTriggerStubWidthRatio - let triggerHeight = h * Layout.topTriggerStubHeightRatio - let triggerSpacing = w * Layout.topTriggerStubSpacingRatio + let triggerWidth = w * layout.topTriggerStubWidthRatio + let triggerHeight = h * layout.topTriggerStubHeightRatio + let triggerSpacing = max(0, w * (layout.topTriggerStubSpacingRatio - triggerStubSpacingAdjustmentRatio)) - let padX = w * Layout.topTriggerStubHorizontalPaddingRatio - let offsetX = h * Layout.topTriggerStubCenterXRatio - let offsetY = h * Layout.topTriggerStubCenterYRatio + let padX = w * layout.topTriggerStubHorizontalPaddingRatio + let offsetX = h * layout.topTriggerStubCenterXRatio + let offsetY = h * layout.topTriggerStubCenterYRatio let finalOffsetX = offsetX * 2 - containerW * 0.012 let finalOffsetY = offsetY * 2 @@ -536,7 +661,8 @@ struct AbstractGamepadView: View { let stackContentWidth = triggerWidth * 2 + triggerSpacing let stackFrameWidth = stackContentWidth + padX * 2 - let leftCenterXInStack = (containerW - stackFrameWidth) * 0.5 + padX + triggerWidth * 0.5 + let panelOriginX = (containerW - panelW) * 0.5 + let leftCenterXInStack = panelOriginX + (panelW - stackFrameWidth) * 0.5 + padX + triggerWidth * 0.5 let rightCenterXInStack = leftCenterXInStack + triggerWidth + triggerSpacing let triggerCenterY = containerH * 0.5 + finalOffsetY @@ -580,68 +706,156 @@ struct AbstractGamepadView: View { toggle(side) } } + + private var liveButtons: Set { + guard let liveSnapshot else { return [] } + var result = Set() + if liveSnapshot.pressedButtons.contains(.a) { result.insert(.a) } + 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(.special) { result.insert(.home) } + if liveSnapshot.pressedButtons.contains(.leftShoulder) { result.insert(.leftShoulder) } + if liveSnapshot.pressedButtons.contains(.rightShoulder) { result.insert(.rightShoulder) } + if liveSnapshot.pressedButtons.contains(.leftStickButton) { result.insert(.leftStick) } + if liveSnapshot.pressedButtons.contains(.rightStickButton) { result.insert(.rightStick) } + return result + } + + private var effectiveActiveButtons: Set { + activeButtons.union(liveButtons) + } + + private var liveDPadHighlight: DPadHighlight { + guard let liveSnapshot else { return .none } + return DPadHighlight(rawValue: liveSnapshot.dpadHighlight) + } + + private var shouldHideInactiveTriggerStubs: Bool { + metricsProfile == .overlay + } + + private var usesOverlayButtonHighlight: Bool { + metricsProfile == .overlay + } + + private var effectiveDPadHighlight: DPadHighlight { + dpadHighlight.union(liveDPadHighlight) + } + + private var leftStickLiveActive: Bool { + guard let liveSnapshot else { return false } + return hypot(liveSnapshot.leftStick.x, liveSnapshot.leftStick.y) > stickLiveDeadzone + } + + private var rightStickLiveActive: Bool { + guard let liveSnapshot else { return false } + return hypot(liveSnapshot.rightStick.x, liveSnapshot.rightStick.y) > stickLiveDeadzone + } + + private var effectiveLeftStickHighlight: StickHighlightMode { + if leftStickLiveActive { + if liveButtons.contains(.leftStick) || leftStickHighlight == .outerOnly || leftStickHighlight == .both { + return .both + } + return .thumbOnly + } + if liveButtons.contains(.leftStick) { + return .both + } + return leftStickHighlight + } + + private var effectiveRightStickHighlight: StickHighlightMode { + if rightStickLiveActive { + if liveButtons.contains(.rightStick) || rightStickHighlight == .outerOnly || rightStickHighlight == .both { + return .both + } + return .thumbOnly + } + if liveButtons.contains(.rightStick) { + return .both + } + return rightStickHighlight + } + + private func thumbOffset(from point: CGPoint, size: CGFloat) -> CGSize { + CGSize(width: size * 0.12 * point.x, height: size * -0.12 * point.y) + } var body: some View { GeometryReader { geo in let containerW = geo.size.width let containerH = geo.size.height - let contentScale = Layout.innerContentScale + let contentScale = layout.innerContentScale + let clampedPanelHorizontalInsetRatio = max(0, min(0.18, panelHorizontalInsetRatio)) + let panelHorizontalInset = containerW * clampedPanelHorizontalInsetRatio + let panelWidth = containerW - panelHorizontalInset * 2 let w = containerW * contentScale let h = containerH * contentScale + let panelW = panelWidth * contentScale - let cornerRadius = h * Layout.bodyCornerRadiusRatio + let cornerRadius = h * layout.bodyCornerRadiusRatio // 尺寸 - let leftStickSize = w * Layout.leftStickSizeRatio - let rightStickSize = w * Layout.rightStickSizeRatio - let dpadSize = w * Layout.dpadSizeRatio - let abxySize = w * Layout.abxySizeRatio - let smallTopButtonDiameter = w * Layout.smallTopButtonDiameterRatio + let leftStickSize = w * layout.leftStickSizeRatio + let rightStickSize = w * layout.rightStickSizeRatio + let dpadSize = w * layout.dpadSizeRatio + let abxySize = w * layout.abxySizeRatio + let smallTopButtonDiameter = w * layout.smallTopButtonDiameterRatio // 位置约束 - let sharedLowerControlCenterY = h * Layout.lowerControlCenterYRatio - let lowerControlHalfSpacing = w * Layout.lowerControlHalfSpacingRatio + let sharedLowerControlCenterY = h * layout.lowerControlCenterYRatio + let clampedCenterCompression = max(-0.25, min(0.25, centerCompressionRatio)) + let lowerControlHalfSpacing = w * max(0.02, layout.lowerControlHalfSpacingRatio - clampedCenterCompression) + let clusterGapAdjustment = w * clusterGapAdjustmentRatio + let upperPrimaryClusterVerticalAdjustment = h * upperPrimaryClusterVerticalAdjustmentRatio + let leftClusterOffsetX = clusterGapAdjustment + let rightClusterOffsetX = -clusterGapAdjustment - let dpadCenterX = -lowerControlHalfSpacing - let rightStickCenterX = lowerControlHalfSpacing + let dpadCenterX = -lowerControlHalfSpacing + leftClusterOffsetX + let rightStickCenterX = lowerControlHalfSpacing + rightClusterOffsetX - let sharedTopButtonCenterY = h * Layout.topButtonCenterYRatio + let sharedTopButtonCenterY = h * layout.topButtonCenterYRatio let minusButtonCenterX = dpadCenterX let plusButtonCenterX = rightStickCenterX let ds4TouchpadCenterX = (dpadCenterX + rightStickCenterX) * 0.5 - let ds4TouchpadWidth = w * Layout.ds4TouchpadWidthRatio - let ds4TouchpadHeight = h * Layout.ds4TouchpadHeightRatio + let ds4TouchpadWidth = w * layout.ds4TouchpadWidthRatio + let ds4TouchpadHeight = h * layout.ds4TouchpadHeightRatio let ds4TouchpadCenterY = (-containerH * 0.5) + (ds4TouchpadHeight * 0.5) - let leftStickCenterX = w * Layout.leftStickCenterXRatio - let leftStickCenterY = h * Layout.leftStickCenterYRatio + let leftStickCenterX = w * (layout.leftStickCenterXRatio + clampedCenterCompression) + leftClusterOffsetX + let leftStickCenterY = h * layout.leftStickCenterYRatio + upperPrimaryClusterVerticalAdjustment - let homeCenterX = w * Layout.homeCenterXRatio - let homeCenterY = h * Layout.homeCenterYRatio + let homeCenterX = w * (layout.homeCenterXRatio - clampedCenterCompression) + rightClusterOffsetX + let homeCenterY = h * layout.homeCenterYRatio // ABXY:与 Home 垂直对齐;与左摇杆同一水平轴 let abxyCenterX = homeCenterX let abxyCenterY = leftStickCenterY - let shoulderBarWidth = w * Layout.shoulderBarWidthRatio - let shoulderBarHeight = h * Layout.shoulderBarHeightRatio - let shoulderBarCenterY = h * Layout.shoulderBarCenterYRatio + let shoulderBarWidth = w * layout.shoulderBarWidthRatio + let shoulderBarHeight = h * layout.shoulderBarHeightRatio + let shoulderBarCenterY = h * layout.shoulderBarCenterYRatio - let shoulderHalfX = (w * 0.5) - (w * Layout.shoulderBarInsetFromSideRatio) - (shoulderBarWidth * 0.5) + let shoulderHalfX = (panelW * 0.5) - (w * layout.shoulderBarInsetFromSideRatio) - (shoulderBarWidth * 0.5) let leftShoulderX = -shoulderHalfX - let rightShoulderX = shoulderHalfX + let rightShoulderX = shoulderHalfX + (w * rightShoulderHorizontalOffsetRatio) let triggerRects = triggerRects( containerW: containerW, containerH: containerH, + panelW: panelWidth, w: w, h: h ) - let triggerStubWidth = w * Layout.topTriggerStubWidthRatio - let triggerStubHeight = h * Layout.topTriggerStubHeightRatio - let triggerStubSpacing = w * Layout.topTriggerStubSpacingRatio + let triggerStubWidth = w * layout.topTriggerStubWidthRatio + let triggerStubHeight = h * layout.topTriggerStubHeightRatio + let triggerStubSpacing = max(0, w * (layout.topTriggerStubSpacingRatio - triggerStubSpacingAdjustmentRatio)) let shoulderHitWidth = shoulderBarWidth * 1.25 let shoulderHitHeight = max(28, shoulderBarHeight * 5.5) @@ -650,7 +864,8 @@ struct AbstractGamepadView: View { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(Color.black.opacity(0.12)) .blur(radius: 18) - .offset(y: h * Layout.bodyShadowYOffsetRatio) + .padding(.horizontal, panelHorizontalInset) + .offset(y: h * layout.bodyShadowYOffsetRatio) RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill( @@ -663,14 +878,17 @@ struct AbstractGamepadView: View { endPoint: .bottomTrailing ) ) + .padding(.horizontal, panelHorizontalInset) .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(Color.white.opacity(0.22), lineWidth: 1.2) + .padding(.horizontal, panelHorizontalInset) ) RoundedRectangle(cornerRadius: cornerRadius * 0.88, style: .continuous) .stroke(Color.white.opacity(0.10), lineWidth: 2) - .padding(w * Layout.innerStrokeInsetRatio) + .padding(.horizontal, panelHorizontalInset) + .padding(w * layout.innerStrokeInsetRatio) ZStack { RadialGradient( @@ -680,7 +898,7 @@ struct AbstractGamepadView: View { ]), center: .top, startRadius: 10, - endRadius: w * Layout.radialHighlightEndRadiusRatio + endRadius: w * layout.radialHighlightEndRadiusRatio ) .clipShape( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) @@ -691,28 +909,36 @@ struct AbstractGamepadView: View { TopTriggerStub( width: triggerStubWidth, height: triggerStubHeight, - highlightMode: leftTriggerHighlight + highlightMode: leftTriggerHighlight, + liveIntensity: liveSnapshot?.leftTrigger ?? 0, + hidesWhenInactive: shouldHideInactiveTriggerStubs, + purpleStrength: triggerPurpleStrength ) TopTriggerStub( width: triggerStubWidth, height: triggerStubHeight, - highlightMode: rightTriggerHighlight + highlightMode: rightTriggerHighlight, + liveIntensity: liveSnapshot?.rightTrigger ?? 0, + hidesWhenInactive: shouldHideInactiveTriggerStubs, + purpleStrength: triggerPurpleStrength ) } - .padding(.horizontal, w * Layout.topTriggerStubHorizontalPaddingRatio) - .offset(y: h * Layout.topTriggerStubCenterYRatio) - .offset(x: h * Layout.topTriggerStubCenterXRatio) - .padding(.horizontal, w * Layout.topTriggerStubHorizontalPaddingRatio) - .offset(y: h * Layout.topTriggerStubCenterYRatio) - .offset(x: h * Layout.topTriggerStubCenterXRatio) + .frame(width: panelWidth) + .padding(.horizontal, w * layout.topTriggerStubHorizontalPaddingRatio) + .offset(y: h * layout.topTriggerStubCenterYRatio) + .offset(x: h * layout.topTriggerStubCenterXRatio) + .padding(.horizontal, w * layout.topTriggerStubHorizontalPaddingRatio) + .offset(y: h * layout.topTriggerStubCenterYRatio) + .offset(x: h * layout.topTriggerStubCenterXRatio) .zIndex(0) // 肩键:如果触点落在对应 trigger frame,就优先切换 trigger ShoulderButtonBarButton( - isHighlighted: activeButtons.contains(.leftShoulder), + isHighlighted: effectiveActiveButtons.contains(.leftShoulder), width: shoulderBarWidth, - height: shoulderBarHeight + height: shoulderBarHeight, + usesOverlayHighlight: usesOverlayButtonHighlight ) { localPoint in handleShoulderTap( localPoint: localPoint, @@ -728,9 +954,10 @@ struct AbstractGamepadView: View { .offset(x: leftShoulderX * 1.005, y: shoulderBarCenterY * 0.902) ShoulderButtonBarButton( - isHighlighted: activeButtons.contains(.rightShoulder), + isHighlighted: effectiveActiveButtons.contains(.rightShoulder), width: shoulderBarWidth, height: shoulderBarHeight, + usesOverlayHighlight: usesOverlayButtonHighlight, flipped: true ) { localPoint in handleShoulderTap( @@ -749,7 +976,8 @@ struct AbstractGamepadView: View { SmallRoundIconButton( symbol: "−", diameter: smallTopButtonDiameter, - isHighlighted: activeButtons.contains(.select) + isHighlighted: effectiveActiveButtons.contains(.select), + usesOverlayHighlight: usesOverlayButtonHighlight ) .offset(x: minusButtonCenterX, y: sharedTopButtonCenterY) .onTapGesture { @@ -759,18 +987,20 @@ struct AbstractGamepadView: View { SmallRoundIconButton( symbol: "+", diameter: smallTopButtonDiameter, - isHighlighted: activeButtons.contains(.start) + isHighlighted: effectiveActiveButtons.contains(.start), + usesOverlayHighlight: usesOverlayButtonHighlight ) .offset(x: plusButtonCenterX, y: sharedTopButtonCenterY) .onTapGesture { toggle(.start) } - if gamepadType == .ps { + if gamepadType == .ps && metricsProfile != .overlay { DS4TouchpadView( width: ds4TouchpadWidth, height: ds4TouchpadHeight, - highlightMode: ds4TouchHighlight + highlightMode: ds4TouchHighlight, + usesOverlayButtonHighlight: usesOverlayButtonHighlight ) .offset(x: ds4TouchpadCenterX, y: ds4TouchpadCenterY) .onTapGesture { @@ -778,7 +1008,12 @@ struct AbstractGamepadView: View { } } - StickView(highlightMode: leftStickHighlight) + StickView( + highlightMode: effectiveLeftStickHighlight, + thumbOffset: thumbOffset(from: liveSnapshot?.leftStick ?? .zero, size: leftStickSize), + usesOverlayButtonHighlight: usesOverlayButtonHighlight, + thumbPurpleStrength: thumbPurpleStrength + ) .frame(width: leftStickSize, height: leftStickSize) .offset(x: leftStickCenterX, y: leftStickCenterY) .onTapGesture { @@ -786,8 +1021,9 @@ struct AbstractGamepadView: View { } DPadXboxStyleView( - highlight: dpadHighlight, - wholePressed: dpadWholePressed + highlight: effectiveDPadHighlight, + wholePressed: dpadWholePressed, + usesOverlayButtonHighlight: usesOverlayButtonHighlight ) { tappedRegion in toggleDPadSelection(for: tappedRegion) } @@ -796,7 +1032,8 @@ struct AbstractGamepadView: View { ABXYClusterView( gamepadType: gamepadType, - highlightedButtons: activeButtons, + highlightedButtons: effectiveActiveButtons, + usesOverlayButtonHighlight: usesOverlayButtonHighlight, onTap: { target in toggle(target) } @@ -804,7 +1041,12 @@ struct AbstractGamepadView: View { .frame(width: abxySize, height: abxySize) .offset(x: abxyCenterX, y: abxyCenterY) - StickView(highlightMode: rightStickHighlight) + StickView( + highlightMode: effectiveRightStickHighlight, + thumbOffset: thumbOffset(from: liveSnapshot?.rightStick ?? .zero, size: rightStickSize), + usesOverlayButtonHighlight: usesOverlayButtonHighlight, + thumbPurpleStrength: thumbPurpleStrength + ) .frame(width: rightStickSize, height: rightStickSize) .offset(x: rightStickCenterX, y: sharedLowerControlCenterY) .onTapGesture { @@ -813,7 +1055,8 @@ struct AbstractGamepadView: View { HomeButtonView( diameter: smallTopButtonDiameter, - isHighlighted: activeButtons.contains(.home) + isHighlighted: effectiveActiveButtons.contains(.home), + usesOverlayHighlight: usesOverlayButtonHighlight ) .offset(x: homeCenterX, y: homeCenterY) .onTapGesture { @@ -825,7 +1068,7 @@ struct AbstractGamepadView: View { } .frame(width: containerW, height: containerH) } - .aspectRatio(Layout.aspectRatio, contentMode: .fit) + .aspectRatio(layout.aspectRatio, contentMode: .fit) .overlay( Group { if showWidgetPicker { @@ -1010,6 +1253,7 @@ struct AbstractGamepadView: View { AbstractGamepadView.selectedCmd = "" } } + } @available(iOS 13.0, *) @@ -1163,22 +1407,86 @@ struct TopTriggerStub: View { var width: CGFloat var height: CGFloat var highlightMode: TriggerHighlightMode = .none + var liveIntensity: CGFloat = 0 + var hidesWhenInactive: Bool = false + var purpleStrength: CGFloat = 1 private var isHighlighted: Bool { - highlightMode != .none + highlightMode != .none || liveIntensity > 0.001 + } + + private var clampedLiveIntensity: Double { + Double(max(0, min(1, liveIntensity))) + } + + private var normalizedPurpleStrength: Double { + Double(max(0, min(2, purpleStrength))) + } + + private func mix(_ lhs: Double, _ rhs: Double, _ t: Double) -> Double { + lhs + (rhs - lhs) * t + } + + private func triggerPurpleColor(light: (r: Double, g: Double, b: Double, a: Double), + base: (r: Double, g: Double, b: Double, a: Double), + deep: (r: Double, g: Double, b: Double, a: Double)) -> Color { + if normalizedPurpleStrength <= 1 { + let t = normalizedPurpleStrength + return Color( + red: mix(light.r, base.r, t), + green: mix(light.g, base.g, t), + blue: mix(light.b, base.b, t), + opacity: mix(light.a, base.a, t) + ) + } else { + let t = normalizedPurpleStrength - 1 + return Color( + red: mix(base.r, deep.r, t), + green: mix(base.g, deep.g, t), + blue: mix(base.b, deep.b, t), + opacity: mix(base.a, deep.a, t) + ) + } + } + + private var gradientColors: [Color] { + if highlightMode == .pad { + return [GamepadPadHighlightStyle.fillTop, GamepadPadHighlightStyle.fillBottom] + } + if highlightMode == .button { + return [GamepadButtonHighlightStyle.fillTop, GamepadButtonHighlightStyle.fillBottom] + } + return [Color.white.opacity(0.97), Color(red: 0.86, green: 0.86, blue: 0.88)] + } + + private var liveOverlayOpacity: Double { + guard clampedLiveIntensity > 0 else { return 0 } + return 0.16 + clampedLiveIntensity * 0.84 + } + + private var strokeColor: Color { + if clampedLiveIntensity > 0 || highlightMode == .pad { + return triggerPurpleColor( + light: (0.56, 0.60, 0.92, 0.58), + base: (0.38, 0.42, 0.82, 0.78), + deep: (0.27, 0.30, 0.68, 0.96) + ) + } + if highlightMode == .button { + return GamepadButtonHighlightStyle.stroke + } + return Color.black.opacity(0.08) + } + + private var inactiveOpacity: Double { + hidesWhenInactive && !isHighlighted ? 0 : 1 } var body: some View { RoundedRectangle(cornerRadius: height * 0.32, style: .continuous) .fill( LinearGradient( - gradient: Gradient(colors: isHighlighted ? [ - highlightMode == .pad ? GamepadPadHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop, - highlightMode == .pad ? GamepadPadHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom - ] : [ - Color.white.opacity(0.97), - Color(red: 0.86, green: 0.86, blue: 0.88) - ]), + gradient: Gradient(colors: gradientColors), startPoint: .top, endPoint: .bottom ) @@ -1186,10 +1494,29 @@ struct TopTriggerStub: View { .frame(width: width, height: height) .overlay( RoundedRectangle(cornerRadius: height * 0.32, style: .continuous) - .stroke( - highlightMode == .pad ? GamepadPadHighlightStyle.stroke : (isHighlighted ? GamepadButtonHighlightStyle.stroke : Color.black.opacity(0.08)), - lineWidth: isHighlighted ? 1.4 : 1 + .fill( + LinearGradient( + gradient: Gradient(colors: [ + triggerPurpleColor( + light: (0.88, 0.89, 1.00, 0.48), + base: (0.82, 0.84, 1.00, 0.92), + deep: (0.66, 0.70, 0.98, 0.98) + ), + triggerPurpleColor( + light: (0.73, 0.76, 0.98, 0.42), + base: (0.56, 0.60, 0.94, 0.84), + deep: (0.38, 0.42, 0.82, 0.96) + ) + ]), + startPoint: .top, + endPoint: .bottom + ) ) + .opacity(liveOverlayOpacity) + ) + .overlay( + RoundedRectangle(cornerRadius: height * 0.32, style: .continuous) + .stroke(strokeColor, lineWidth: isHighlighted ? 1.4 : 1) ) .overlay( LinearGradient( @@ -1205,6 +1532,7 @@ struct TopTriggerStub: View { ) ) .shadow(color: Color.black.opacity(0.10), radius: 2, x: 0, y: 1) + .opacity(inactiveOpacity) } } // MARK: - 顶部 +/- 小按钮 @@ -1214,15 +1542,23 @@ struct SmallRoundIconButton: View { var symbol: String var diameter: CGFloat = 35 var isHighlighted: Bool = false + var usesOverlayHighlight: Bool = false + + private var highlightedColors: [Color] { + usesOverlayHighlight + ? [GamepadOverlayButtonHighlightStyle.fillTop, GamepadOverlayButtonHighlightStyle.fillBottom] + : [Color(red: 1.00, green: 0.92, blue: 0.50), Color(red: 0.95, green: 0.73, blue: 0.22)] + } + + private var highlightedStroke: Color { + usesOverlayHighlight ? GamepadOverlayButtonHighlightStyle.stroke : Color.orange.opacity(0.7) + } var body: some View { Circle() .fill( LinearGradient( - gradient: Gradient(colors: isHighlighted ? [ - Color(red: 1.00, green: 0.92, blue: 0.50), - Color(red: 0.95, green: 0.73, blue: 0.22) - ] : [ + gradient: Gradient(colors: isHighlighted ? highlightedColors : [ Color(red: 0.23, green: 0.82, blue: 0.86), Color(red: 0.16, green: 0.68, blue: 0.74) ]), @@ -1233,7 +1569,7 @@ struct SmallRoundIconButton: View { .overlay( Circle() .stroke( - isHighlighted ? Color.orange.opacity(0.7) : Color.black.opacity(0.16), + isHighlighted ? highlightedStroke : Color.black.opacity(0.16), lineWidth: isHighlighted ? 1.5 : 1 ) ) @@ -1269,21 +1605,22 @@ struct DS4TouchpadView: View { var width: CGFloat var height: CGFloat var highlightMode: DS4TouchHighlightMode = .none + var usesOverlayButtonHighlight: Bool = false private var isHighlighted: Bool { highlightMode != .none } private var fillTop: Color { - highlightMode == .pad ? GamepadPadHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop + highlightMode == .pad ? GamepadPadHighlightStyle.fillTop : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop) } private var fillBottom: Color { - highlightMode == .pad ? GamepadPadHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom + highlightMode == .pad ? GamepadPadHighlightStyle.fillBottom : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom) } private var strokeColor: Color { - highlightMode == .pad ? GamepadPadHighlightStyle.stroke : GamepadButtonHighlightStyle.stroke + highlightMode == .pad ? GamepadPadHighlightStyle.stroke : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.stroke : GamepadButtonHighlightStyle.stroke) } private var dotColor: Color { @@ -1457,6 +1794,30 @@ private enum GamepadButtonHighlightStyle { static let stroke = Color.orange.opacity(0.75) } +@available(iOS 13.0, *) +private enum GamepadOverlayButtonHighlightStyle { + static let fillTop = Color(red: 1.00, green: 0.94, blue: 0.68) + static let fillBottom = Color(red: 0.95, green: 0.76, blue: 0.26) + static let stroke = Color(red: 0.86, green: 0.52, blue: 0.08).opacity(0.88) +} + +@available(iOS 13.0, *) +private enum GamepadOverlayShoulderHighlightStyle { + static let fillTop = Color(red: 1.00, green: 0.85, blue: 0.53) + static let fillBottom = Color(red: 0.94, green: 0.625, blue: 0.176) +} + +@available(iOS 13.0, *) +private enum GamepadStickThumbStyle { + static let neutralFillTop = Color(red: 0.95, green: 0.95, blue: 0.96) + static let neutralFillBottom = Color(red: 0.86, green: 0.86, blue: 0.88) + static let activeFillTop = GamepadPadHighlightStyle.fillTop.opacity(0.92) + static let activeFillBottom = GamepadPadHighlightStyle.fillBottom.opacity(0.88) + static let ring = Color.black.opacity(0.125) + static let activeRing = GamepadPadHighlightStyle.stroke + static let ringWidth: CGFloat = 1.35 +} + @available(iOS 13.0, *) struct StickView: View { @@ -1470,7 +1831,11 @@ struct StickView: View { } var highlightMode: StickHighlightMode = .none - + var thumbOffset: CGSize = .zero + var usePadHighlightForOuter: Bool = false + var usesOverlayButtonHighlight: Bool = false + var thumbPurpleStrength: CGFloat = 1 + private var outerHighlighted: Bool { highlightMode == .outerOnly || highlightMode == .both } @@ -1478,26 +1843,72 @@ struct StickView: View { private var thumbHighlighted: Bool { highlightMode == .thumbOnly || highlightMode == .both } + + private var normalizedThumbPurpleStrength: Double { + Double(max(0, min(2, thumbPurpleStrength))) + } + + private func mix(_ lhs: Double, _ rhs: Double, _ t: Double) -> Double { + lhs + (rhs - lhs) * t + } + + private func thumbPurpleColor(light: (r: Double, g: Double, b: Double, a: Double), + base: (r: Double, g: Double, b: Double, a: Double), + deep: (r: Double, g: Double, b: Double, a: Double)) -> Color { + if normalizedThumbPurpleStrength <= 1 { + let t = normalizedThumbPurpleStrength + return Color( + red: mix(light.r, base.r, t), + green: mix(light.g, base.g, t), + blue: mix(light.b, base.b, t), + opacity: mix(light.a, base.a, t) + ) + } else { + let t = normalizedThumbPurpleStrength - 1 + return Color( + red: mix(base.r, deep.r, t), + green: mix(base.g, deep.g, t), + blue: mix(base.b, deep.b, t), + opacity: mix(base.a, deep.a, t) + ) + } + } + + private var thumbActiveFillTop: Color { + thumbPurpleColor( + light: (0.90, 0.91, 1.00, 0.55), + base: (0.82, 0.84, 1.00, 0.92), + deep: (0.66, 0.70, 0.98, 1.00) + ) + } + + private var thumbActiveFillBottom: Color { + thumbPurpleColor( + light: (0.74, 0.78, 0.99, 0.48), + base: (0.56, 0.60, 0.94, 0.84), + deep: (0.37, 0.41, 0.80, 0.98) + ) + } + + private var thumbActiveStroke: Color { + thumbPurpleColor( + light: (0.58, 0.62, 0.92, 0.58), + base: (0.38, 0.42, 0.82, 0.90), + deep: (0.26, 0.30, 0.67, 1.00) + ) + } var body: some View { GeometryReader { geo in let s = min(geo.size.width, geo.size.height) ZStack { - Circle() - .fill(Color.black.opacity(0.18)) - .frame(width: s * Ratio.base, height: s * Ratio.base) - .offset( - x: s * Ratio.shadowOffsetX, - y: s * Ratio.shadowOffsetY - ) - Circle() .fill( LinearGradient( gradient: Gradient(colors: outerHighlighted ? [ - GamepadButtonHighlightStyle.fillTop, - GamepadButtonHighlightStyle.fillBottom + usePadHighlightForOuter ? GamepadPadHighlightStyle.fillTop : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop), + usePadHighlightForOuter ? GamepadPadHighlightStyle.fillBottom : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom) ] : [ Color.white.opacity(0.98), Color(red: 0.88, green: 0.88, blue: 0.90) @@ -1510,33 +1921,35 @@ struct StickView: View { .overlay( Circle() .stroke( - outerHighlighted ? GamepadButtonHighlightStyle.stroke : Color.black.opacity(0.10), + outerHighlighted ? (usePadHighlightForOuter ? GamepadPadHighlightStyle.stroke : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.stroke : GamepadButtonHighlightStyle.stroke)) : Color.black.opacity(0.10), lineWidth: outerHighlighted ? 1.5 : 1 ) ) - Circle() - .fill( - LinearGradient( - gradient: Gradient(colors: thumbHighlighted ? [ - GamepadPadHighlightStyle.fillTop, - GamepadPadHighlightStyle.fillBottom - ] : [ - Color(red: 0.95, green: 0.95, blue: 0.96), - Color(red: 0.86, green: 0.86, blue: 0.88) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: s * Ratio.thumb, height: s * Ratio.thumb) - .overlay( - Circle() - .stroke( - thumbHighlighted ? GamepadPadHighlightStyle.stroke : Color.black.opacity(0.06), - lineWidth: 1 + ZStack { + Circle() + .fill( + LinearGradient( + gradient: Gradient(colors: thumbHighlighted ? [ + thumbActiveFillTop, + thumbActiveFillBottom + ] : [ + GamepadStickThumbStyle.neutralFillTop, + GamepadStickThumbStyle.neutralFillBottom + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing ) - ) + ) + + Circle() + .stroke( + thumbHighlighted ? thumbActiveStroke : GamepadStickThumbStyle.ring, + lineWidth: GamepadStickThumbStyle.ringWidth + ) + } + .frame(width: s * Ratio.thumb, height: s * Ratio.thumb) + .offset(thumbOffset) } .frame(width: geo.size.width, height: geo.size.height) } @@ -1547,6 +1960,7 @@ struct StickView: View { struct DPadXboxStyleView: View { var highlight: DPadHighlight = .none var wholePressed: Bool = false + var usesOverlayButtonHighlight: Bool = false var onTapRegion: ((DPadHighlight) -> Void)? = nil var body: some View { @@ -1569,21 +1983,17 @@ struct DPadXboxStyleView: View { DPadCrossBaseView( arm: arm, highlight: highlight, - wholePressed: wholePressed + wholePressed: wholePressed, + usesOverlayButtonHighlight: usesOverlayButtonHighlight ) Group { - Text("▴").offset(y: -thickness * 0.95) - Text("▾").offset(y: thickness * 0.95) - Text("◂").offset(x: -thickness * 0.95) - Text("▸").offset(x: thickness * 0.95) + dpadArrow("▴", direction: .up).offset(y: -thickness * 0.95) + dpadArrow("▾", direction: .down).offset(y: thickness * 0.95) + dpadArrow("◂", direction: .left).offset(x: -thickness * 0.95) + dpadArrow("▸", direction: .right).offset(x: thickness * 0.95) } .font(.system(size: s * 0.14, weight: .bold)) - .foregroundColor( - highlight == .none - ? Color.black.opacity(0.12) - : Color.black.opacity(0.22) - ) } .contentShape(Rectangle()) .gesture( @@ -1617,6 +2027,12 @@ struct DPadXboxStyleView: View { return dy < 0 ? .up : .down } } + + private func dpadArrow(_ symbol: String, direction: DPadHighlight) -> some View { + let isOn = wholePressed || highlight.contains(direction) + return Text(symbol) + .foregroundColor(isOn ? Color.black.opacity(0.22) : Color.black.opacity(0.12)) + } } @available(iOS 13.0.0, *) @@ -1624,6 +2040,7 @@ struct DPadCrossBaseView: View { let arm: CGFloat let highlight: DPadHighlight let wholePressed: Bool + let usesOverlayButtonHighlight: Bool var body: some View { ZStack { @@ -1645,8 +2062,8 @@ struct DPadCrossBaseView: View { .fill( LinearGradient( gradient: Gradient(colors: isOn ? [ - usePadHighlight ? GamepadPadHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop, - usePadHighlight ? GamepadPadHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom + usePadHighlight ? GamepadPadHighlightStyle.fillTop : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop), + usePadHighlight ? GamepadPadHighlightStyle.fillBottom : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom) ] : [ Color.white.opacity(0.98), Color(red: 0.88, green: 0.88, blue: 0.90) @@ -1658,7 +2075,7 @@ struct DPadCrossBaseView: View { .overlay( DPadSegmentShape(direction: dir) .stroke( - isOn ? (usePadHighlight ? GamepadPadHighlightStyle.stroke : GamepadButtonHighlightStyle.stroke) : Color.clear, + isOn ? (usePadHighlight ? GamepadPadHighlightStyle.stroke : (usesOverlayButtonHighlight ? GamepadOverlayButtonHighlightStyle.stroke : GamepadButtonHighlightStyle.stroke)) : Color.clear, lineWidth: 1 ) ) @@ -1763,6 +2180,7 @@ struct DPadSegmentShape: Shape { struct ABXYClusterView: View { let gamepadType: GamepadType let highlightedButtons: Set + let usesOverlayButtonHighlight: Bool let onTap: (GamepadToggleTarget) -> Void var body: some View { @@ -1772,22 +2190,22 @@ struct ABXYClusterView: View { let size = s * 0.31 ZStack { - LetterButton(title: leftButtonTitle, size: size, isHighlighted: highlightedButtons.contains(.x)) + LetterButton(title: leftButtonTitle, systemSymbolName: leftButtonSymbolName, size: size, isHighlighted: highlightedButtons.contains(.x), usesOverlayHighlight: usesOverlayButtonHighlight) .frame(width: size, height: size) .offset(x: -offset, y: 0) .onTapGesture { onTap(.x) } - LetterButton(title: topButtonTitle, size: size, isHighlighted: highlightedButtons.contains(.y)) + LetterButton(title: topButtonTitle, systemSymbolName: topButtonSymbolName, size: size, isHighlighted: highlightedButtons.contains(.y), usesOverlayHighlight: usesOverlayButtonHighlight) .frame(width: size, height: size) .offset(x: 0, y: -offset) .onTapGesture { onTap(.y) } - LetterButton(title: rightButtonTitle, size: size, isHighlighted: highlightedButtons.contains(.b)) + LetterButton(title: rightButtonTitle, systemSymbolName: rightButtonSymbolName, size: size, isHighlighted: highlightedButtons.contains(.b), usesOverlayHighlight: usesOverlayButtonHighlight) .frame(width: size, height: size) .offset(x: offset, y: 0) .onTapGesture { onTap(.b) } - LetterButton(title: bottomButtonTitle, size: size, isHighlighted: highlightedButtons.contains(.a)) + LetterButton(title: bottomButtonTitle, systemSymbolName: bottomButtonSymbolName, size: size, isHighlighted: highlightedButtons.contains(.a), usesOverlayHighlight: usesOverlayButtonHighlight) .frame(width: size, height: size) .offset(x: 0, y: offset) .onTapGesture { onTap(.a) } @@ -1797,27 +2215,55 @@ struct ABXYClusterView: View { } private var leftButtonTitle: String { - gamepadType == .ps ? "□" : "X" + gamepadType == .ps ? "" : "X" } private var topButtonTitle: String { - gamepadType == .ps ? "△" : "Y" + gamepadType == .ps ? "" : "Y" } private var rightButtonTitle: String { - gamepadType == .ps ? "○" : "B" + gamepadType == .ps ? "" : "B" } private var bottomButtonTitle: String { - gamepadType == .ps ? "×" : "A" + gamepadType == .ps ? "" : "A" + } + + private var leftButtonSymbolName: String? { + gamepadType == .ps ? "square" : nil + } + + private var topButtonSymbolName: String? { + gamepadType == .ps ? "triangle" : nil + } + + private var rightButtonSymbolName: String? { + gamepadType == .ps ? "circle" : nil + } + + private var bottomButtonSymbolName: String? { + gamepadType == .ps ? "xmark" : nil } } @available(iOS 13.0, *) struct LetterButton: View { var title: String + var systemSymbolName: String? = nil var size: CGFloat var isHighlighted: Bool = false + var usesOverlayHighlight: Bool = false + + private var highlightedColors: [Color] { + usesOverlayHighlight + ? [GamepadOverlayButtonHighlightStyle.fillTop, GamepadOverlayButtonHighlightStyle.fillBottom] + : [Color(red: 1.00, green: 0.96, blue: 0.74), Color(red: 0.95, green: 0.78, blue: 0.32)] + } + + private var highlightedStroke: Color { + usesOverlayHighlight ? GamepadOverlayButtonHighlightStyle.stroke : Color.orange.opacity(0.75) + } var body: some View { ZStack { @@ -1829,10 +2275,7 @@ struct LetterButton: View { Circle() .fill( LinearGradient( - gradient: Gradient(colors: isHighlighted ? [ - Color(red: 1.00, green: 0.96, blue: 0.74), - Color(red: 0.95, green: 0.78, blue: 0.32) - ] : [ + gradient: Gradient(colors: isHighlighted ? highlightedColors : [ Color.white.opacity(0.98), Color(red: 0.88, green: 0.88, blue: 0.90) ]), @@ -1843,14 +2286,20 @@ struct LetterButton: View { .overlay( Circle() .stroke( - isHighlighted ? Color.orange.opacity(0.75) : Color.black.opacity(0.12), + isHighlighted ? highlightedStroke : Color.black.opacity(0.12), lineWidth: isHighlighted ? 1.5 : 1 ) ) - Text(title) - .font(.system(size: size * 0.53, weight: .medium, design: .rounded)) - .foregroundColor(isHighlighted ? Color.black.opacity(0.58) : Color.black.opacity(0.28)) + if let systemSymbolName { + Image(systemName: systemSymbolName) + .font(.system(size: size * 0.42, weight: .semibold, design: .rounded)) + .foregroundColor(isHighlighted ? Color.black.opacity(0.58) : Color.black.opacity(0.28)) + } else { + Text(title) + .font(.system(size: size * 0.53, weight: .medium, design: .rounded)) + .foregroundColor(isHighlighted ? Color.black.opacity(0.58) : Color.black.opacity(0.28)) + } } } } @@ -1861,9 +2310,20 @@ struct LetterButton: View { struct HomeButtonView: View { var diameter: CGFloat var isHighlighted: Bool = false + var usesOverlayHighlight: Bool = false private var innerInset: CGFloat { diameter * 0.06 } private var shadowOffsetY: CGFloat { diameter * 0.05 } + + private var highlightedColors: [Color] { + usesOverlayHighlight + ? [GamepadOverlayButtonHighlightStyle.fillTop, GamepadOverlayButtonHighlightStyle.fillBottom] + : [Color(red: 1.00, green: 0.92, blue: 0.52), Color(red: 0.96, green: 0.74, blue: 0.24)] + } + + private var highlightedStroke: Color { + usesOverlayHighlight ? GamepadOverlayButtonHighlightStyle.stroke : Color.orange.opacity(0.75) + } var body: some View { ZStack { @@ -1875,10 +2335,7 @@ struct HomeButtonView: View { Circle() .fill( LinearGradient( - gradient: Gradient(colors: isHighlighted ? [ - Color(red: 1.00, green: 0.92, blue: 0.52), - Color(red: 0.96, green: 0.74, blue: 0.24) - ] : [ + gradient: Gradient(colors: isHighlighted ? highlightedColors : [ Color(red: 0.25, green: 0.86, blue: 0.90), Color(red: 0.18, green: 0.72, blue: 0.78) ]), @@ -1889,7 +2346,7 @@ struct HomeButtonView: View { .overlay( Circle() .stroke( - isHighlighted ? Color.orange.opacity(0.75) : Color.black.opacity(0.16), + isHighlighted ? highlightedStroke : Color.black.opacity(0.16), lineWidth: isHighlighted ? 1.5 : 1 ) ) @@ -1913,12 +2370,13 @@ struct ShoulderButtonBarButton: View { var isHighlighted: Bool var width: CGFloat var height: CGFloat + var usesOverlayHighlight: Bool = false var flipped: Bool = false var action: (CGPoint) -> Void var body: some View { ZStack { - ShoulderButtonBar(isHighlighted: isHighlighted) + ShoulderButtonBar(isHighlighted: isHighlighted, usesOverlayHighlight: usesOverlayHighlight) .scaleEffect(x: flipped ? -1 : 1, y: 1) .frame(width: width, height: height) @@ -1939,6 +2397,7 @@ struct ShoulderButtonBarButton: View { @available(iOS 13.0, *) struct ShoulderButtonBar: View { var isHighlighted: Bool = false + var usesOverlayHighlight: Bool = false var body: some View { GeometryReader { geo in @@ -1970,8 +2429,8 @@ struct ShoulderButtonBar: View { .stroke( LinearGradient( gradient: Gradient(colors: isHighlighted ? [ - Color(red: 1.00, green: 0.95, blue: 0.65), - Color(red: 0.95, green: 0.77, blue: 0.28) + usesOverlayHighlight ? GamepadOverlayShoulderHighlightStyle.fillTop : Color(red: 1.00, green: 0.95, blue: 0.65), + usesOverlayHighlight ? GamepadOverlayShoulderHighlightStyle.fillBottom : Color(red: 0.95, green: 0.77, blue: 0.28) ] : [ .white.opacity(0.22), Color(red: 0.86, green: 0.86, blue: 0.88) diff --git a/VoidLinkSwiftUI/VoidLinkSwiftUIApp.swift b/VoidLinkSwiftUI/VoidLinkSwiftUIApp.swift index 01150d49..d6e93eaf 100644 --- a/VoidLinkSwiftUI/VoidLinkSwiftUIApp.swift +++ b/VoidLinkSwiftUI/VoidLinkSwiftUIApp.swift @@ -5,7 +5,7 @@ import SwiftUI struct VoidLinkSwiftUIApp: App { var body: some Scene { WindowGroup { - WidgetPickerView() + AbstractGamepadView() } } } diff --git a/VoidLinkSwiftUI/WidgetPickerView.swift b/VoidLinkSwiftUI/WidgetPickerView.swift index 25428ab0..dbf55398 100644 --- a/VoidLinkSwiftUI/WidgetPickerView.swift +++ b/VoidLinkSwiftUI/WidgetPickerView.swift @@ -414,6 +414,16 @@ struct WidgetPickerView: View { allowsShortcutCombo: false, forcedComboMode: nil ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "🎮 overlay switch"), + cmd: "GAMEPADOVERLAY", + tip: SwiftLocalizationHelper.localizedString(forKey: "Switch on/off a floating gamepad overlay with live input feedback"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), FunctionalButtonOption( label: SwiftLocalizationHelper.localizedString(forKey: "Pressure curve"), cmd: "PRESSURECURVE", @@ -654,6 +664,7 @@ struct WidgetPickerView: View { case .gamepad: AbstractGamepadView( gamepadType: selectedGamepadType, + metricsProfile: .picker, canSelectCommand: { canSelectCommand($0, source: .gamepad) }, isCommandSelected: { selectedCmds.contains($0) }, onCommandSelected: { appendCommand($0, source: .gamepad) }, @@ -1841,13 +1852,10 @@ struct WidgetPickerView: View { if gamepadCommands.contains(cmd) { return .gamepad } - if keyboardCommands.contains(cmd) { - return .keyboard - } if functionalCommands.contains(cmd) { return .functional } - return nil + return .keyboard } private func canonicalCommand(for cmd: String) -> String { @@ -1877,12 +1885,6 @@ struct WidgetPickerView: View { ] } - private var keyboardCommands: Set { - Set(CommandManager.keyboardButtonMappings.keys) - .union(Set(CommandManager.mouseButtonMappings.keys)) - .union(keyboardPadCommands) - } - private var functionalCommands: Set { Set(functionalButtonOptions.map(\.cmd)).union(gyroCommands) }