1731 lines
66 KiB
Swift
1731 lines
66 KiB
Swift
//
|
|
// ControllerUtil.swift
|
|
// VoidLink
|
|
//
|
|
// Created by True砖家 on 2025/11/19.
|
|
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
|
|
//
|
|
|
|
|
|
import GameController
|
|
import Combine
|
|
import CoreGraphics
|
|
import Foundation
|
|
#if !VOIDLINK_PREVIEW
|
|
import Collections
|
|
#endif
|
|
#if os(iOS) && !VOIDLINK_PREVIEW
|
|
import SwiftUI
|
|
import UIKit
|
|
#endif
|
|
|
|
@objc enum ControllerElementType: Int {
|
|
case button
|
|
case stick
|
|
case stickAxis
|
|
case touchpad
|
|
case cluster
|
|
case undefined
|
|
}
|
|
|
|
@objc enum ControllerElementPosition: Int {
|
|
case left
|
|
case right
|
|
case middle
|
|
case undefined
|
|
}
|
|
|
|
@objc enum ControllerElement: Int32 {
|
|
// Face buttons
|
|
case a = 0x1000
|
|
case b = 0x2000
|
|
case x = 0x4000
|
|
case y = 0x8000
|
|
|
|
// DPad
|
|
case dpadUp = 0x0001
|
|
case dpadDown = 0x0002
|
|
case dpadLeft = 0x0004
|
|
case dpadRight = 0x0008
|
|
|
|
// Shoulder
|
|
case leftShoulder = 0x0100
|
|
case rightShoulder = 0x0200
|
|
|
|
// Stick click
|
|
case leftStickButton = 0x0040
|
|
case rightStickButton = 0x0080
|
|
|
|
// Menu/Back/Special
|
|
case start = 0x0010 // PLAY_FLAG
|
|
case select = 0x0020 // BACK_FLAG
|
|
case special = 0x0400
|
|
|
|
// Extended buttons (Sunshine only)
|
|
case paddle1 = 0x010000
|
|
case paddle2 = 0x020000
|
|
case paddle3 = 0x040000
|
|
case paddle4 = 0x080000
|
|
case touchpadButton = 0x100000
|
|
case misc = 0x200000
|
|
|
|
case leftTrigger = 0x400000
|
|
case rightTrigger = 0x800000
|
|
|
|
case leftStick = 0x800010
|
|
case leftStickX = 0x80011
|
|
case leftStickY = 0x800012
|
|
case rightStick = 0x800020
|
|
case rightStickX = 0x800021
|
|
case rightStickY = 0x800022
|
|
|
|
case dpad = 0x800013
|
|
case abxy = 0x800023
|
|
|
|
case null = 0xFFFFFF
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .a: return "A"
|
|
case .b: return "B"
|
|
case .x: return "X"
|
|
case .y: return "Y"
|
|
|
|
case .dpadUp: return "DPadU".localized
|
|
case .dpadDown: return "DPadD".localized
|
|
case .dpadLeft: return "DPadL".localized
|
|
case .dpadRight: return "DPadR".localized
|
|
|
|
case .leftShoulder: return "LB"
|
|
case .rightShoulder: return "RB"
|
|
|
|
case .leftStickButton: return "LS"
|
|
case .rightStickButton: return "RS"
|
|
|
|
case .start: return "Start"
|
|
case .select: return "Select"
|
|
case .special: return "Home"
|
|
|
|
case .paddle1: return "Paddle1".localized
|
|
case .paddle2: return "Paddle2".localized
|
|
case .paddle3: return "Paddle3".localized
|
|
case .paddle4: return "Paddle4".localized
|
|
case .touchpadButton: return "TouchBtn".localized
|
|
case .misc: return "Misc"
|
|
|
|
case .leftTrigger: return "LT"
|
|
case .rightTrigger: return "RT"
|
|
|
|
case .leftStick: return "LStick".localized
|
|
case .leftStickX: return "LStickX".localized
|
|
case .leftStickY: return "LStickY".localized
|
|
case .rightStick: return "RStick".localized
|
|
case .rightStickX: return "RStickX".localized
|
|
case .rightStickY: return "RStickY".localized
|
|
|
|
case .dpad: return "DPad".localized
|
|
case .abxy: return "ABXY".localized
|
|
|
|
case .null: return "Null".localized
|
|
}
|
|
}
|
|
|
|
var symbol: String {
|
|
switch self {
|
|
case .dpadUp:
|
|
if #available(iOS 15.0, *) {return "dpad.up.filled"}
|
|
else {return ""}
|
|
case .dpadDown:
|
|
if #available(iOS 15.0, *) {return "dpad.down.filled"}
|
|
else {return ""}
|
|
case .dpadLeft:
|
|
if #available(iOS 15.0, *) {return "dpad.left.filled"}
|
|
else {return ""}
|
|
case .dpadRight:
|
|
if #available(iOS 15.0, *) {return "dpad.right.filled"}
|
|
else {return ""}
|
|
case .dpad:
|
|
if #available(iOS 14.0, *) {return "dpad"}
|
|
else {return ""}
|
|
case .a:
|
|
if #available(iOS 15.0, *) {return "circle.grid.cross.down.filled"}
|
|
else {return ""}
|
|
case .b:
|
|
if #available(iOS 15.0, *) {return "circle.grid.cross.right.filled"}
|
|
else {return ""}
|
|
case .x:
|
|
if #available(iOS 15.0, *) {return "circle.grid.cross.left.filled"}
|
|
else {return ""}
|
|
case .y:
|
|
if #available(iOS 15.0, *) {return "circle.grid.cross.up.filled"}
|
|
else {return ""}
|
|
case .abxy:
|
|
if #available(iOS 14.0, *) {return "circle.grid.cross"}
|
|
else {return ""}
|
|
|
|
default: return ""
|
|
}
|
|
}
|
|
|
|
var type: ControllerElementType {
|
|
switch self {
|
|
case .a: return .button
|
|
case .b: return .button
|
|
case .x: return .button
|
|
case .y: return .button
|
|
|
|
case .dpadUp: return .button
|
|
case .dpadDown: return .button
|
|
case .dpadLeft: return .button
|
|
case .dpadRight: return .button
|
|
|
|
case .leftShoulder: return .button
|
|
case .rightShoulder: return .button
|
|
|
|
case .leftStickButton: return .button
|
|
case .rightStickButton: return .button
|
|
|
|
case .start: return .button
|
|
case .select: return .button
|
|
case .special: return .button
|
|
|
|
case .paddle1: return .button
|
|
case .paddle2: return .button
|
|
case .paddle3: return .button
|
|
case .paddle4: return .button
|
|
case .touchpadButton: return .button
|
|
case .misc: return .button
|
|
|
|
case .leftTrigger: return .button
|
|
case .rightTrigger: return .button
|
|
|
|
case .leftStick: return .stick
|
|
case .leftStickX: return .stickAxis
|
|
case .leftStickY: return .stickAxis
|
|
case .rightStick: return .stick
|
|
case .rightStickX: return .stickAxis
|
|
case .rightStickY: return .stickAxis
|
|
|
|
case .dpad: return .cluster
|
|
case .abxy: return .cluster
|
|
|
|
case .null: return .undefined
|
|
}
|
|
}
|
|
|
|
var position: ControllerElementPosition {
|
|
switch self {
|
|
case .a: return .right
|
|
case .b: return .right
|
|
case .x: return .right
|
|
case .y: return .right
|
|
|
|
case .dpadUp: return .left
|
|
case .dpadDown: return .left
|
|
case .dpadLeft: return .left
|
|
case .dpadRight: return .left
|
|
|
|
case .leftShoulder: return .left
|
|
case .rightShoulder: return .right
|
|
|
|
case .leftStickButton: return .left
|
|
case .rightStickButton: return .right
|
|
|
|
case .start: return .right
|
|
case .select: return .left
|
|
case .special: return .undefined
|
|
|
|
case .paddle1: return .right
|
|
case .paddle2: return .right
|
|
case .paddle3: return .left
|
|
case .paddle4: return .left
|
|
case .touchpadButton: return .middle
|
|
case .misc: return .undefined
|
|
|
|
case .leftTrigger: return .left
|
|
case .rightTrigger: return .right
|
|
|
|
case .leftStick: return .left
|
|
case .leftStickX: return .left
|
|
case .leftStickY: return .left
|
|
case .rightStick: return .right
|
|
case .rightStickX: return .right
|
|
case .rightStickY: return .right
|
|
|
|
case .abxy: return .right
|
|
case .dpad: return .left
|
|
|
|
case .null: return .undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc enum ControllerEmulation: UInt8 {
|
|
case xbox = 0x01 // LI_CTYPE_XBOX
|
|
case ps = 0x02 // LI_CTYPE_PS
|
|
case psEnhancedHaptic = 0xFE
|
|
case xboxAndPs = 0x00 // LI_CTYPE_UNKNOWN
|
|
}
|
|
|
|
@objc enum ControllerHardware: UInt8 {
|
|
case generic
|
|
case g8PlusMFi
|
|
}
|
|
|
|
@objc protocol ControllerUtilDelegate: AnyObject {
|
|
func isStreaming() -> Bool
|
|
func isInAppView() -> Bool
|
|
}
|
|
|
|
@objc class ControllerUtil: NSObject {
|
|
@objc static weak var delegate: ControllerUtilDelegate?
|
|
|
|
#if !VOIDLINK_PREVIEW
|
|
@objc static var hasDualSenseController: Bool {
|
|
if #available(iOS 14.5, *) {
|
|
for gcController in GCController.controllers() {
|
|
if gcController.extendedGamepad is GCDualSenseGamepad {return true}
|
|
}
|
|
return false
|
|
}
|
|
else {return false}
|
|
}
|
|
|
|
@objc static func hasControllerAccelerometer(_ controller: GCController) -> Bool {
|
|
guard let motion = controller.motion else { return false }
|
|
|
|
if #available(iOS 14.0, tvOS 14.0, *) {
|
|
if motion.hasGravityAndUserAcceleration {
|
|
return true
|
|
}
|
|
|
|
if controller.extendedGamepad is GCDualShockGamepad {
|
|
return true
|
|
}
|
|
|
|
if #available(iOS 14.5, tvOS 14.5, *) {
|
|
if controller.extendedGamepad is GCDualSenseGamepad {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private struct DualSensePCMChunk {
|
|
let controllerNumber: UInt16
|
|
let flags: UInt8
|
|
let sequenceNumber: UInt32
|
|
let presentationTimeUs: UInt64
|
|
let frameCount: UInt16
|
|
let pcmData: Data
|
|
}
|
|
|
|
private final class AuthoredEngineState {
|
|
var engine: OpaquePointer?
|
|
var renderHostAnchorUs: UInt64?
|
|
var renderLocalAnchor = 0.0
|
|
var diagnosticFrameCounter: UInt32 = 0
|
|
|
|
init?() {
|
|
var config = AhAuthoredConfig()
|
|
guard ah_authored_config_init(&config, 48_000) == AH_STATUS_OK else {
|
|
return nil
|
|
}
|
|
guard ah_authored_create(&config, &engine) == AH_STATUS_OK, engine != nil else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
ah_authored_destroy(engine)
|
|
}
|
|
|
|
func renderDelay(for timestampUs: UInt64, resetTimeline: Bool) -> TimeInterval {
|
|
let now = ProcessInfo.processInfo.systemUptime
|
|
if resetTimeline || renderHostAnchorUs == nil {
|
|
renderHostAnchorUs = timestampUs
|
|
renderLocalAnchor = now + 0.002
|
|
}
|
|
let anchor = renderHostAnchorUs ?? timestampUs
|
|
let hostDelta = timestampUs >= anchor ? Double(timestampUs - anchor) / 1_000_000.0 : 0
|
|
return min(max(renderLocalAnchor + hostDelta - now, 0), 0.025)
|
|
}
|
|
}
|
|
|
|
private final class AuthoredIrRenderState {
|
|
var renderHostAnchorUs: UInt64?
|
|
var renderLocalAnchor = 0.0
|
|
var diagnosticFrameCounter: UInt32 = 0
|
|
|
|
func renderDelay(for timestampUs: UInt64, resetTimeline: Bool) -> TimeInterval {
|
|
let now = ProcessInfo.processInfo.systemUptime
|
|
if resetTimeline || renderHostAnchorUs == nil {
|
|
renderHostAnchorUs = timestampUs
|
|
renderLocalAnchor = now + 0.002
|
|
}
|
|
let anchor = renderHostAnchorUs ?? timestampUs
|
|
let hostDelta = timestampUs >= anchor ? Double(timestampUs - anchor) / 1_000_000.0 : 0
|
|
return min(max(renderLocalAnchor + hostDelta - now, 0), 0.025)
|
|
}
|
|
}
|
|
|
|
private static let dualSenseHapticsLock = NSLock()
|
|
private static let dualSenseHapticsWorker = DispatchQueue(
|
|
label: "com.voidlink.dualsense-authored-haptics",
|
|
qos: .userInteractive
|
|
)
|
|
private static let maxQueuedDualSensePCMChunks = 8
|
|
private static let defaultDualSenseControllerHapticsEQ: [Float] = [0.78, 0.98, 1.59, 0.175, 1.2]
|
|
private static let defaultDeviceDualSenseHapticsEQ: [Float] = [18, 0.98, 1.59, 0.9, 1.2]
|
|
private static let dualSenseControllerAmplitudeScale: Float = 1.15
|
|
private static let dualSenseControllerTransientScale: Float = 1.0
|
|
private static let dualSenseControllerSharpnessScale: Float = 1.15
|
|
private static let dualSenseControllerMirrorMixedHandles = false
|
|
private static var dualSensePCMQueue = Deque<DualSensePCMChunk>()
|
|
private static var dualSenseWorkerScheduled = false
|
|
private static var dualSenseForcedDiscontinuities = Set<UInt16>()
|
|
private static var dualSenseAuthoredEngines = [UInt16: AuthoredEngineState]()
|
|
private static var dualSenseIrRenderStates = [UInt16: AuthoredIrRenderState]()
|
|
private static let authoredHapticsRouteLock = NSLock()
|
|
private static var authoredHapticsPreference = AuthoredHapticsPreference.auto
|
|
private static var authoredHapticsLastRoutedToDualSense = false
|
|
private static var authoredHapticsLastRoute = AuthoredHapticsRoute.off
|
|
|
|
private enum AuthoredHapticsPreference: Int {
|
|
case auto = 0
|
|
case device = 1
|
|
case swapped = 2
|
|
case off = 3
|
|
}
|
|
|
|
private enum AuthoredHapticsRoute {
|
|
case dualSense
|
|
case device
|
|
case off
|
|
}
|
|
|
|
#if os(iOS)
|
|
private static var dualSenseHapticsEQOverlayWindow: UIWindow?
|
|
private static weak var dualSenseHapticsEQPreviousKeyWindow: UIWindow?
|
|
private static var didPresentDualSenseHapticsEQ = false
|
|
#endif
|
|
|
|
fileprivate static func dualSenseHapticsEQSnapshot() -> [Float] {
|
|
defaultDeviceDualSenseHapticsEQ
|
|
}
|
|
|
|
fileprivate static func defaultDualSenseHapticsEQSnapshot() -> [Float] {
|
|
defaultDeviceDualSenseHapticsEQ
|
|
}
|
|
|
|
private static func dualSenseHapticsEQSnapshot(for route: AuthoredHapticsRoute) -> [Float] {
|
|
switch route {
|
|
case .dualSense:
|
|
return defaultDualSenseControllerHapticsEQ
|
|
case .device, .off:
|
|
return defaultDeviceDualSenseHapticsEQ
|
|
}
|
|
}
|
|
|
|
private static func compensatedDualSenseControllerValues(
|
|
_ values: (amplitude: Float, sharpness: Float, transient: Float),
|
|
highBandRatio: Float
|
|
) -> (amplitude: Float, sharpness: Float, transient: Float) {
|
|
let lowFrequencyTrim = 0.82 + 0.18 * min(max(highBandRatio, 0), 1)
|
|
let amplitude = min(max(
|
|
pow(values.amplitude, 0.50) * dualSenseControllerAmplitudeScale * lowFrequencyTrim,
|
|
0
|
|
), 1)
|
|
let sharpness = min(max(
|
|
pow(values.sharpness, 0.8) * dualSenseControllerSharpnessScale,
|
|
0
|
|
), 1)
|
|
let transient = min(max(
|
|
pow(values.transient, 0.9) * dualSenseControllerTransientScale,
|
|
0
|
|
), 1)
|
|
return (amplitude, sharpness, transient)
|
|
}
|
|
|
|
private static func authoredHighBandRatio(for lane: AhAuthoredLaneFrame?) -> Float {
|
|
1 - min(max(lane?.low_band_ratio ?? 1, 0), 1)
|
|
}
|
|
|
|
private static func mixedDualSenseControllerHandleValues(
|
|
left: (amplitude: Float, sharpness: Float, transient: Float),
|
|
right: (amplitude: Float, sharpness: Float, transient: Float)
|
|
) -> (
|
|
left: (amplitude: Float, sharpness: Float, transient: Float),
|
|
right: (amplitude: Float, sharpness: Float, transient: Float)
|
|
) {
|
|
guard dualSenseControllerMirrorMixedHandles else {
|
|
return (left, right)
|
|
}
|
|
|
|
let amplitude = min(max(sqrt(
|
|
(left.amplitude * left.amplitude + right.amplitude * right.amplitude) * 0.5
|
|
), 0), 1)
|
|
let amplitudeSum = left.amplitude + right.amplitude
|
|
let sharpness = amplitudeSum > 0.000_001
|
|
? min(max(
|
|
(left.sharpness * left.amplitude + right.sharpness * right.amplitude) / amplitudeSum,
|
|
0
|
|
), 1)
|
|
: min(max(max(left.sharpness, right.sharpness), 0), 1)
|
|
let transient = min(max(max(left.transient, right.transient), 0), 1)
|
|
let mixed = (amplitude: amplitude, sharpness: sharpness, transient: transient)
|
|
return (mixed, mixed)
|
|
}
|
|
|
|
fileprivate static func previewDualSenseHapticsEQ(_ values: [Float]) {
|
|
}
|
|
|
|
fileprivate static func saveDualSenseHapticsEQ(_ values: [Float]) {
|
|
}
|
|
|
|
private static func refreshAuthoredHapticsPreference() -> AuthoredHapticsPreference {
|
|
let rawValue = Int(DataManager().getSettings().hapticEngine.intValue)
|
|
let preference = AuthoredHapticsPreference(rawValue: rawValue) ?? .auto
|
|
authoredHapticsRouteLock.lock()
|
|
authoredHapticsPreference = preference
|
|
authoredHapticsRouteLock.unlock()
|
|
return preference
|
|
}
|
|
|
|
private static func selectAuthoredHapticsRoute(
|
|
controllerNumber: UInt16,
|
|
controllerSupport: ControllerSupport
|
|
) -> AuthoredHapticsRoute {
|
|
switch refreshAuthoredHapticsPreference() {
|
|
case .off:
|
|
return .off
|
|
case .device:
|
|
return .device
|
|
case .auto, .swapped:
|
|
let hasDualSense = controllerSupport.hasDualSenseController(controllerNumber)
|
|
authoredHapticsRouteLock.lock()
|
|
authoredHapticsLastRoutedToDualSense = hasDualSense
|
|
authoredHapticsRouteLock.unlock()
|
|
return hasDualSense ? .dualSense : .device
|
|
}
|
|
}
|
|
|
|
private static func stopAuthoredHapticsRoute(
|
|
_ route: AuthoredHapticsRoute,
|
|
controllerNumber: UInt16,
|
|
controllerSupport: ControllerSupport
|
|
) {
|
|
switch route {
|
|
case .dualSense:
|
|
controllerSupport.renderDualSenseHaptics(
|
|
controllerNumber,
|
|
leftAmplitude: 0,
|
|
leftSharpness: 0,
|
|
leftTransient: 0,
|
|
rightAmplitude: 0,
|
|
rightSharpness: 0,
|
|
rightTransient: 0,
|
|
delaySeconds: 0
|
|
)
|
|
case .device:
|
|
controllerSupport.renderDeviceDualSenseHaptics(
|
|
controllerNumber,
|
|
leftAmplitude: 0,
|
|
leftSharpness: 0,
|
|
leftTransient: 0,
|
|
rightAmplitude: 0,
|
|
rightSharpness: 0,
|
|
rightTransient: 0,
|
|
delaySeconds: 0
|
|
)
|
|
case .off:
|
|
break
|
|
}
|
|
}
|
|
|
|
private static func updateAuthoredHapticsRouteTransition(
|
|
to route: AuthoredHapticsRoute,
|
|
controllerNumber: UInt16,
|
|
controllerSupport: ControllerSupport
|
|
) {
|
|
authoredHapticsRouteLock.lock()
|
|
let previousRoute = authoredHapticsLastRoute
|
|
authoredHapticsLastRoute = route
|
|
authoredHapticsLastRoutedToDualSense = route == .dualSense
|
|
authoredHapticsRouteLock.unlock()
|
|
|
|
if previousRoute != route {
|
|
stopAuthoredHapticsRoute(
|
|
previousRoute,
|
|
controllerNumber: controllerNumber,
|
|
controllerSupport: controllerSupport
|
|
)
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
@available(iOS 14.0, *)
|
|
@objc static func presentDualSenseHapticsEQIfNeeded() {
|
|
dispatchPrecondition(condition: .onQueue(.main))
|
|
guard !didPresentDualSenseHapticsEQ,
|
|
dualSenseHapticsEQOverlayWindow == nil,
|
|
let keyWindow = UIApplication.shared.connectedScenes
|
|
.compactMap({ $0 as? UIWindowScene })
|
|
.flatMap(\.windows)
|
|
.first(where: { $0.isKeyWindow }),
|
|
let windowScene = keyWindow.windowScene else {
|
|
return
|
|
}
|
|
|
|
didPresentDualSenseHapticsEQ = true
|
|
dualSenseHapticsEQPreviousKeyWindow = keyWindow
|
|
|
|
let store = DualSenseHapticsEQStore(values: dualSenseHapticsEQSnapshot())
|
|
let rootView = DualSenseHapticsEQView(store: store) {
|
|
closeDualSenseHapticsEQ()
|
|
}
|
|
let hostingController = UIHostingController(rootView: rootView)
|
|
hostingController.view.backgroundColor = .clear
|
|
|
|
let overlayWindow = UIWindow(windowScene: windowScene)
|
|
overlayWindow.frame = windowScene.screen.bounds
|
|
overlayWindow.windowLevel = .alert + 1
|
|
overlayWindow.backgroundColor = .clear
|
|
overlayWindow.rootViewController = hostingController
|
|
dualSenseHapticsEQOverlayWindow = overlayWindow
|
|
overlayWindow.makeKeyAndVisible()
|
|
}
|
|
|
|
fileprivate static func closeDualSenseHapticsEQ() {
|
|
dualSenseHapticsEQOverlayWindow?.isHidden = true
|
|
dualSenseHapticsEQOverlayWindow?.rootViewController = nil
|
|
dualSenseHapticsEQOverlayWindow = nil
|
|
dualSenseHapticsEQPreviousKeyWindow?.makeKey()
|
|
dualSenseHapticsEQPreviousKeyWindow = nil
|
|
}
|
|
#endif
|
|
|
|
@objc(enqueueDualSenseHapticsPCMWithControllerNumber:flags:sequenceNumber:presentationTimeUs:frameCount:pcmData:)
|
|
static func enqueueDualSenseHapticsPCM(
|
|
controllerNumber: UInt16,
|
|
flags: UInt8,
|
|
sequenceNumber: UInt32,
|
|
presentationTimeUs: UInt64,
|
|
frameCount: UInt16,
|
|
pcmData: Data
|
|
) {
|
|
let expectedByteCount = Int(frameCount) * 2 * MemoryLayout<Int16>.size
|
|
guard frameCount <= 480, pcmData.count == expectedByteCount else {
|
|
NSLog("DualSense haptics: rejected malformed PCM chunk for controller %u", controllerNumber)
|
|
return
|
|
}
|
|
|
|
let chunk = DualSensePCMChunk(
|
|
controllerNumber: controllerNumber,
|
|
flags: flags,
|
|
sequenceNumber: sequenceNumber,
|
|
presentationTimeUs: presentationTimeUs,
|
|
frameCount: frameCount,
|
|
pcmData: pcmData
|
|
)
|
|
|
|
dualSenseHapticsLock.lock()
|
|
if dualSensePCMQueue.count >= maxQueuedDualSensePCMChunks,
|
|
let dropped = dualSensePCMQueue.popFirst() {
|
|
dualSenseForcedDiscontinuities.insert(dropped.controllerNumber)
|
|
}
|
|
dualSensePCMQueue.append(chunk)
|
|
let shouldScheduleWorker = !dualSenseWorkerScheduled
|
|
dualSenseWorkerScheduled = true
|
|
dualSenseHapticsLock.unlock()
|
|
|
|
if shouldScheduleWorker {
|
|
dualSenseHapticsWorker.async {
|
|
drainDualSensePCMQueue()
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(enqueueDualSenseHapticsIRV2WithControllerNumber:flags:sourceSequenceNumber:timestampUs:sourceFrameCount:leftRms:leftPeak:leftTransient:leftLowRatio:leftZeroCrossHz:rightRms:rightPeak:rightTransient:rightLowRatio:rightZeroCrossHz:laneCorrelation:)
|
|
static func enqueueDualSenseHapticsIRV2(
|
|
controllerNumber: UInt16,
|
|
flags: UInt8,
|
|
sourceSequenceNumber: UInt32,
|
|
timestampUs: UInt64,
|
|
sourceFrameCount: UInt32,
|
|
leftRms: Float,
|
|
leftPeak: Float,
|
|
leftTransient: Float,
|
|
leftLowRatio: Float,
|
|
leftZeroCrossHz: Float,
|
|
rightRms: Float,
|
|
rightPeak: Float,
|
|
rightTransient: Float,
|
|
rightLowRatio: Float,
|
|
rightZeroCrossHz: Float,
|
|
laneCorrelation: Float
|
|
) {
|
|
dualSenseHapticsWorker.async {
|
|
processDualSenseIRV2Frame(
|
|
controllerNumber: controllerNumber,
|
|
flags: flags,
|
|
sourceSequenceNumber: sourceSequenceNumber,
|
|
timestampUs: timestampUs,
|
|
sourceFrameCount: sourceFrameCount,
|
|
left: makeAuthoredLaneFrame(
|
|
rmsAmplitude: leftRms,
|
|
peakAmplitude: leftPeak,
|
|
transientStrength: leftTransient,
|
|
lowBandRatio: leftLowRatio,
|
|
zeroCrossingRateHz: leftZeroCrossHz
|
|
),
|
|
right: makeAuthoredLaneFrame(
|
|
rmsAmplitude: rightRms,
|
|
peakAmplitude: rightPeak,
|
|
transientStrength: rightTransient,
|
|
lowBandRatio: rightLowRatio,
|
|
zeroCrossingRateHz: rightZeroCrossHz
|
|
),
|
|
laneCorrelation: laneCorrelation
|
|
)
|
|
}
|
|
}
|
|
|
|
@objc static func stopAllDualSenseHaptics() {
|
|
ControllerSupport.sharedInstance()?.cancelScheduledDualSenseHaptics()
|
|
|
|
dualSenseHapticsLock.lock()
|
|
dualSensePCMQueue.removeAll(keepingCapacity: true)
|
|
dualSenseForcedDiscontinuities.removeAll(keepingCapacity: true)
|
|
dualSenseHapticsLock.unlock()
|
|
|
|
dualSenseHapticsWorker.async {
|
|
let controllerNumbers = Array(
|
|
Set(dualSenseAuthoredEngines.keys).union(dualSenseIrRenderStates.keys)
|
|
)
|
|
dualSenseAuthoredEngines.removeAll(keepingCapacity: false)
|
|
dualSenseIrRenderStates.removeAll(keepingCapacity: false)
|
|
for controllerNumber in controllerNumbers {
|
|
renderDualSenseHaptics(controllerNumber: controllerNumber, left: nil, right: nil, delay: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func makeAuthoredLaneFrame(
|
|
rmsAmplitude: Float,
|
|
peakAmplitude: Float,
|
|
transientStrength: Float,
|
|
lowBandRatio: Float,
|
|
zeroCrossingRateHz: Float
|
|
) -> AhAuthoredLaneFrame {
|
|
var lane = AhAuthoredLaneFrame()
|
|
lane.rms_amplitude = rmsAmplitude
|
|
lane.peak_amplitude = peakAmplitude
|
|
lane.transient_strength = transientStrength
|
|
lane.low_band_ratio = lowBandRatio
|
|
lane.zero_crossing_rate_hz = zeroCrossingRateHz
|
|
return lane
|
|
}
|
|
|
|
private static func processDualSenseIRV2Frame(
|
|
controllerNumber: UInt16,
|
|
flags: UInt8,
|
|
sourceSequenceNumber: UInt32,
|
|
timestampUs: UInt64,
|
|
sourceFrameCount: UInt32,
|
|
left: AhAuthoredLaneFrame,
|
|
right: AhAuthoredLaneFrame,
|
|
laneCorrelation: Float
|
|
) {
|
|
let isDiscontinuity = (flags & 0x01) != 0
|
|
let isStreamEnd = (flags & 0x04) != 0
|
|
let isSilent = (flags & 0x08) != 0
|
|
let state = dualSenseIrRenderStates[controllerNumber] ?? AuthoredIrRenderState()
|
|
dualSenseIrRenderStates[controllerNumber] = state
|
|
let delay = state.renderDelay(
|
|
for: timestampUs,
|
|
resetTimeline: isDiscontinuity || state.diagnosticFrameCounter == 0
|
|
)
|
|
state.diagnosticFrameCounter &+= 1
|
|
|
|
renderDualSenseHaptics(
|
|
controllerNumber: controllerNumber,
|
|
left: isSilent ? nil : left,
|
|
right: isSilent ? nil : right,
|
|
laneCorrelation: laneCorrelation,
|
|
logDiagnostics: state.diagnosticFrameCounter % 100 == 0,
|
|
appliesEQ: false,
|
|
delay: delay
|
|
)
|
|
|
|
if isStreamEnd {
|
|
renderDualSenseHaptics(
|
|
controllerNumber: controllerNumber,
|
|
left: nil,
|
|
right: nil,
|
|
appliesEQ: false,
|
|
delay: delay + 0.005
|
|
)
|
|
dualSenseIrRenderStates.removeValue(forKey: controllerNumber)
|
|
}
|
|
|
|
_ = sourceSequenceNumber
|
|
_ = sourceFrameCount
|
|
}
|
|
|
|
private static func drainDualSensePCMQueue() {
|
|
while true {
|
|
dualSenseHapticsLock.lock()
|
|
guard var chunk = dualSensePCMQueue.popFirst() else {
|
|
dualSenseWorkerScheduled = false
|
|
dualSenseHapticsLock.unlock()
|
|
return
|
|
}
|
|
if dualSenseForcedDiscontinuities.remove(chunk.controllerNumber) != nil {
|
|
chunk = DualSensePCMChunk(
|
|
controllerNumber: chunk.controllerNumber,
|
|
flags: chunk.flags | 0x04,
|
|
sequenceNumber: chunk.sequenceNumber,
|
|
presentationTimeUs: chunk.presentationTimeUs,
|
|
frameCount: chunk.frameCount,
|
|
pcmData: chunk.pcmData
|
|
)
|
|
}
|
|
dualSenseHapticsLock.unlock()
|
|
processDualSensePCMChunk(chunk)
|
|
}
|
|
}
|
|
|
|
private static func processDualSensePCMChunk(_ chunk: DualSensePCMChunk) {
|
|
let isStreamStart = (chunk.flags & 0x01) != 0
|
|
let isStreamEnd = (chunk.flags & 0x02) != 0
|
|
let isDiscontinuity = (chunk.flags & 0x04) != 0
|
|
|
|
if isStreamStart || dualSenseAuthoredEngines[chunk.controllerNumber] == nil {
|
|
dualSenseAuthoredEngines[chunk.controllerNumber] = AuthoredEngineState()
|
|
NSLog("DualSense haptics: stream started for controller %u", chunk.controllerNumber)
|
|
}
|
|
guard let state = dualSenseAuthoredEngines[chunk.controllerNumber],
|
|
let engine = state.engine else {
|
|
NSLog("DualSense haptics: failed to create authored engine for controller %u", chunk.controllerNumber)
|
|
return
|
|
}
|
|
|
|
var inputFlags: UInt32 = 0
|
|
if isStreamStart { inputFlags |= UInt32(AH_AUTHORED_INPUT_STREAM_START) }
|
|
if isDiscontinuity { inputFlags |= UInt32(AH_AUTHORED_INPUT_DISCONTINUITY) }
|
|
if isStreamEnd { inputFlags |= UInt32(AH_AUTHORED_INPUT_STREAM_END) }
|
|
|
|
var samples = [Int16](repeating: 0, count: Int(chunk.frameCount) * 2)
|
|
chunk.pcmData.withUnsafeBytes { rawBuffer in
|
|
let bytes = rawBuffer.bindMemory(to: UInt8.self)
|
|
for index in samples.indices {
|
|
let offset = index * 2
|
|
let value = UInt16(bytes[offset]) | (UInt16(bytes[offset + 1]) << 8)
|
|
samples[index] = Int16(bitPattern: value)
|
|
}
|
|
}
|
|
|
|
let outputCapacity = ah_authored_get_max_output_frames(
|
|
engine,
|
|
UInt32(chunk.frameCount),
|
|
inputFlags
|
|
)
|
|
var outputFrames = [AhAuthoredHapticFrame](
|
|
repeating: AhAuthoredHapticFrame(),
|
|
count: Int(outputCapacity)
|
|
)
|
|
var outputCount: UInt32 = 0
|
|
let status = samples.withUnsafeBufferPointer { sampleBuffer -> AhStatus in
|
|
var input = AhAuthoredProcessInput()
|
|
input.struct_size = UInt32(MemoryLayout<AhAuthoredProcessInput>.size)
|
|
input.interleaved_pcm = sampleBuffer.baseAddress
|
|
input.frame_count = UInt32(chunk.frameCount)
|
|
input.flags = inputFlags
|
|
input.first_sample_time_us = chunk.presentationTimeUs
|
|
input.sequence_number = chunk.sequenceNumber
|
|
return ah_authored_process_i16(
|
|
engine,
|
|
&input,
|
|
&outputFrames,
|
|
outputCapacity,
|
|
&outputCount
|
|
)
|
|
}
|
|
|
|
guard status == AH_STATUS_OK || status == AH_STATUS_OUTPUT_AVAILABLE else {
|
|
NSLog("DualSense haptics: authored conversion failed with status %d", status)
|
|
ah_authored_reset(engine)
|
|
renderDualSenseHaptics(controllerNumber: chunk.controllerNumber, left: nil, right: nil, delay: 0)
|
|
return
|
|
}
|
|
|
|
var finalRenderDelay = 0.0
|
|
for index in 0..<Int(outputCount) {
|
|
let frame = outputFrames[index]
|
|
let silent = (frame.flags & UInt32(AH_AUTHORED_FRAME_SILENT)) != 0
|
|
let resetTimeline = isStreamStart || isDiscontinuity ||
|
|
(frame.flags & UInt32(AH_AUTHORED_FRAME_DISCONTINUITY)) != 0
|
|
let delay = state.renderDelay(for: frame.timestamp_us, resetTimeline: resetTimeline && index == 0)
|
|
finalRenderDelay = max(finalRenderDelay, delay)
|
|
state.diagnosticFrameCounter &+= 1
|
|
renderDualSenseHaptics(
|
|
controllerNumber: chunk.controllerNumber,
|
|
left: silent ? nil : frame.lanes.0,
|
|
right: silent ? nil : frame.lanes.1,
|
|
laneCorrelation: frame.lane_correlation,
|
|
logDiagnostics: state.diagnosticFrameCounter % 100 == 0,
|
|
appliesEQ: false,
|
|
delay: delay
|
|
)
|
|
}
|
|
|
|
if isStreamEnd {
|
|
renderDualSenseHaptics(
|
|
controllerNumber: chunk.controllerNumber,
|
|
left: nil,
|
|
right: nil,
|
|
delay: finalRenderDelay + 0.005
|
|
)
|
|
dualSenseAuthoredEngines.removeValue(forKey: chunk.controllerNumber)
|
|
NSLog("DualSense haptics: stream ended for controller %u", chunk.controllerNumber)
|
|
}
|
|
}
|
|
|
|
@objc static var dualSenseHapticTransient: Float = 1.0
|
|
private static func renderDualSenseHaptics(
|
|
controllerNumber: UInt16,
|
|
left: AhAuthoredLaneFrame?,
|
|
right: AhAuthoredLaneFrame?,
|
|
laneCorrelation: Float = 0,
|
|
logDiagnostics: Bool = false,
|
|
appliesEQ: Bool = true,
|
|
delay: TimeInterval
|
|
) {
|
|
guard let controllerSupport = ControllerSupport.sharedInstance() else {
|
|
return
|
|
}
|
|
|
|
let route = selectAuthoredHapticsRoute(
|
|
controllerNumber: controllerNumber,
|
|
controllerSupport: controllerSupport
|
|
)
|
|
updateAuthoredHapticsRouteTransition(
|
|
to: route,
|
|
controllerNumber: controllerNumber,
|
|
controllerSupport: controllerSupport
|
|
)
|
|
|
|
guard route != .off else {
|
|
return
|
|
}
|
|
|
|
let eq = appliesEQ ? dualSenseHapticsEQSnapshot(for: route) : [1, 1, 1, 1, 1] as [Float]
|
|
let masterGain = eq[0]
|
|
let lowBandGain = eq[1]
|
|
let highBandGain = eq[2]
|
|
let transientGain = eq[3]
|
|
let sharpnessGain = eq[4]
|
|
|
|
func rawIrValues(for lane: AhAuthoredLaneFrame?) -> (amplitude: Float, sharpness: Float, transient: Float) {
|
|
guard let lane else {
|
|
return (0, 0, 0)
|
|
}
|
|
let amplitude = min(max(lane.rms_amplitude, 0), 1)
|
|
let sharpness = min(max(1 - lane.low_band_ratio, 0), 1)
|
|
let transient = min(max(lane.transient_strength, 0), 1)
|
|
return (amplitude, sharpness, transient)
|
|
}
|
|
|
|
func renderValues1(for lane: AhAuthoredLaneFrame?) -> (amplitude: Float, sharpness: Float, transient: Float) {
|
|
let lowBandRatio = min(max(lane?.low_band_ratio ?? 1, 0), 1)
|
|
let highBandRatio = 1 - lowBandRatio
|
|
let effectiveLowBandGain = pow(lowBandGain, 1.6)
|
|
let effectiveHighBandGain = pow(highBandGain, 1.6)
|
|
let weightedLowEnergy = lowBandRatio * effectiveLowBandGain * effectiveLowBandGain
|
|
let weightedHighEnergy = highBandRatio * effectiveHighBandGain * effectiveHighBandGain
|
|
let weightedEnergy = weightedLowEnergy + weightedHighEnergy
|
|
let bandGain = weightedEnergy.squareRoot()
|
|
let equalizedHighBandRatio = weightedEnergy > 0.000_001
|
|
? weightedHighEnergy / weightedEnergy
|
|
: 0
|
|
|
|
let zeroCrossingFrequency = min(max((lane?.zero_crossing_rate_hz ?? 0) * 0.5, 0), 400)
|
|
let hasTextureModel = zeroCrossingFrequency >= 40
|
|
let representativeFrequency = hasTextureModel
|
|
? zeroCrossingFrequency
|
|
: 120 * lowBandRatio + 300 * highBandRatio
|
|
let spectralSharpness = min(max((representativeFrequency - 40) / 360, 0), 1)
|
|
let immediateSharpness = equalizedHighBandRatio.squareRoot()
|
|
let synthesizedSharpness = hasTextureModel
|
|
? 0.9 * immediateSharpness + 0.1 * spectralSharpness
|
|
: immediateSharpness
|
|
let sourceTransient = min(max(lane?.transient_strength ?? 0, 0), 1)
|
|
let rmsAmplitude = min(max(lane?.rms_amplitude ?? 0, 0), 1)
|
|
let transientBandTrim = 0.55 + 0.35 * lowBandRatio + 0.10 * equalizedHighBandRatio
|
|
let lowContinuousTrim = 0.78 + 0.22 * lowBandRatio
|
|
let highContinuousTrim = 0.62 - 0.30 * equalizedHighBandRatio
|
|
let renderedTransient = min(max(
|
|
pow(sourceTransient, 0.45) * sqrt(masterGain) * transientGain *
|
|
bandGain * transientBandTrim * 1.35,
|
|
0
|
|
), 1)
|
|
let continuousShare = 1 - 0.85 * min(
|
|
pow(sourceTransient, 0.55) * transientGain,
|
|
1
|
|
)
|
|
let lowBody = pow(rmsAmplitude, 1.85) *
|
|
(1 - highBandRatio) * effectiveLowBandGain * 0.55
|
|
let highTexture = pow(rmsAmplitude, 0.55) *
|
|
pow(highBandRatio, 1.35) * effectiveHighBandGain * 0.09
|
|
let midBodyTrim = 1 - 0.55 * min(max((representativeFrequency - 90) / 140, 0), 1) *
|
|
(1 - highBandRatio)
|
|
let continuousAmplitude = (lowBody * lowContinuousTrim + highTexture * highContinuousTrim) *
|
|
masterGain * midBodyTrim * continuousShare
|
|
return (
|
|
min(max(continuousAmplitude, 0), 1),
|
|
min(max(synthesizedSharpness * sharpnessGain, 0), 1),
|
|
renderedTransient
|
|
)
|
|
}
|
|
|
|
func renderValues2(for lane: AhAuthoredLaneFrame?) -> (amplitude: Float, sharpness: Float, transient: Float) {
|
|
guard let lane else {
|
|
return (0, 0, 0)
|
|
}
|
|
|
|
let useBuiltinHaptic = route == .device
|
|
|
|
let rmsAmplitude = min(max(lane.rms_amplitude, 0), 1)
|
|
|
|
let lowBandRatio = min(max(lane.low_band_ratio, 0), 1)
|
|
let highBandRatio = 1 - lowBandRatio
|
|
let amplitudeLowBandGain: Float = 1.0
|
|
let amplitudeHighBandGain: Float = 1.0
|
|
let bandGain = lowBandRatio * amplitudeLowBandGain + highBandRatio * amplitudeHighBandGain
|
|
let sourceSharpness = min(max(highBandRatio, 0), 1)
|
|
let sharpnessLowGain: Float = 0.62
|
|
let sharpnessMidGain: Float = 0.82
|
|
let sharpnessHighGain: Float = 1.2
|
|
let sharpnessLowWeight = min(max((0.50 - sourceSharpness) / 0.50, 0), 1)
|
|
let sharpnessHighWeight = min(max((sourceSharpness - 0.50) / 0.50, 0), 1)
|
|
let sharpnessMidWeight = 1 - max(sharpnessLowWeight, sharpnessHighWeight)
|
|
let sharpnessGain = sharpnessLowWeight * sharpnessLowGain +
|
|
sharpnessMidWeight * sharpnessMidGain +
|
|
sharpnessHighWeight * sharpnessHighGain
|
|
let sharpness = min(max(sourceSharpness * sharpnessGain, 0), 1)
|
|
|
|
let transient = min(max(lane.transient_strength, 0), 1)
|
|
let amplitudeCeiling: Float = 1.0
|
|
let transientGain: Float = useBuiltinHaptic ? 1.03 : 0.343
|
|
let transientCeiling: Float = useBuiltinHaptic ? 1.03 : 0.6
|
|
let transientBottom: Float = useBuiltinHaptic ? 0.14 : 0.0
|
|
|
|
return (
|
|
min(rmsAmplitude * bandGain, amplitudeCeiling),
|
|
sharpness,
|
|
min(max(transientBottom, transient * transientGain * 1.6666666667 * dualSenseHapticTransient), transientCeiling)
|
|
)
|
|
}
|
|
|
|
var leftValues = appliesEQ ? renderValues1(for: left) : renderValues2(for: left)
|
|
var rightValues = appliesEQ ? renderValues1(for: right) : renderValues2(for: right)
|
|
|
|
if appliesEQ {
|
|
let positiveCorrelation = min(max(laneCorrelation, 0), 1)
|
|
let commonHighBandRatio = (
|
|
authoredHighBandRatio(for: left) + authoredHighBandRatio(for: right)
|
|
) * 0.5
|
|
let dualSenseCenterBlendScale = 0.10 + 0.55 * commonHighBandRatio
|
|
let centerBlendScale: Float = route == .dualSense ? dualSenseCenterBlendScale : 0.85
|
|
let centerBlend = min(max((positiveCorrelation - 0.1) / 0.9, 0), 1) * centerBlendScale
|
|
if centerBlend > 0 {
|
|
let commonAmplitude = sqrt(
|
|
(leftValues.amplitude * leftValues.amplitude +
|
|
rightValues.amplitude * rightValues.amplitude) * 0.5
|
|
)
|
|
let amplitudeSum = leftValues.amplitude + rightValues.amplitude
|
|
let commonSharpness = amplitudeSum > 0.000_001
|
|
? (leftValues.sharpness * leftValues.amplitude +
|
|
rightValues.sharpness * rightValues.amplitude) / amplitudeSum
|
|
: max(leftValues.sharpness, rightValues.sharpness)
|
|
let commonTransient = max(leftValues.transient, rightValues.transient)
|
|
|
|
leftValues.amplitude += (commonAmplitude - leftValues.amplitude) * centerBlend
|
|
rightValues.amplitude += (commonAmplitude - rightValues.amplitude) * centerBlend
|
|
leftValues.sharpness += (commonSharpness - leftValues.sharpness) * centerBlend
|
|
rightValues.sharpness += (commonSharpness - rightValues.sharpness) * centerBlend
|
|
leftValues.transient += (commonTransient - leftValues.transient) * centerBlend
|
|
rightValues.transient += (commonTransient - rightValues.transient) * centerBlend
|
|
}
|
|
}
|
|
|
|
/*
|
|
if logDiagnostics {
|
|
NSLog(
|
|
"DS5 authored: c=%u route=%@ srcL[rms=%.3f high=%.3f hz=%.0f] srcR[rms=%.3f high=%.3f hz=%.0f] corr=%.2f outL[a=%.3f s=%.3f t=%.3f] outR[a=%.3f s=%.3f t=%.3f]",
|
|
controllerNumber,
|
|
route == .dualSense ? "dualsense" : "device",
|
|
Double(left?.rms_amplitude ?? 0),
|
|
Double(1 - min(max(left?.low_band_ratio ?? 1, 0), 1)),
|
|
Double(min(max((left?.zero_crossing_rate_hz ?? 0) * 0.5, 0), 400)),
|
|
Double(right?.rms_amplitude ?? 0),
|
|
Double(1 - min(max(right?.low_band_ratio ?? 1, 0), 1)),
|
|
Double(min(max((right?.zero_crossing_rate_hz ?? 0) * 0.5, 0), 400)),
|
|
Double(laneCorrelation),
|
|
Double(leftValues.amplitude),
|
|
Double(leftValues.sharpness),
|
|
Double(leftValues.transient),
|
|
Double(rightValues.amplitude),
|
|
Double(rightValues.sharpness),
|
|
Double(rightValues.transient)
|
|
)
|
|
}
|
|
*/
|
|
|
|
switch route {
|
|
case .dualSense:
|
|
if appliesEQ {
|
|
leftValues = compensatedDualSenseControllerValues(
|
|
leftValues,
|
|
highBandRatio: authoredHighBandRatio(for: left)
|
|
)
|
|
rightValues = compensatedDualSenseControllerValues(
|
|
rightValues,
|
|
highBandRatio: authoredHighBandRatio(for: right)
|
|
)
|
|
let mixedValues = mixedDualSenseControllerHandleValues(left: leftValues, right: rightValues)
|
|
leftValues = mixedValues.left
|
|
rightValues = mixedValues.right
|
|
}
|
|
controllerSupport.renderDualSenseHaptics(
|
|
controllerNumber,
|
|
leftAmplitude: leftValues.amplitude,
|
|
leftSharpness: leftValues.sharpness,
|
|
leftTransient: leftValues.transient,
|
|
rightAmplitude: rightValues.amplitude,
|
|
rightSharpness: rightValues.sharpness,
|
|
rightTransient: rightValues.transient,
|
|
delaySeconds: delay
|
|
)
|
|
case .device:
|
|
controllerSupport.renderDeviceDualSenseHaptics(
|
|
controllerNumber,
|
|
leftAmplitude: leftValues.amplitude,
|
|
leftSharpness: leftValues.sharpness,
|
|
leftTransient: leftValues.transient,
|
|
rightAmplitude: rightValues.amplitude,
|
|
rightSharpness: rightValues.sharpness,
|
|
rightTransient: rightValues.transient,
|
|
delaySeconds: delay
|
|
)
|
|
case .off:
|
|
break
|
|
}
|
|
}
|
|
#endif
|
|
|
|
static private let stickMaxOffset:CGFloat = 0x7FFE
|
|
@objc static var navigationActionTriggered:Bool = false
|
|
@objc static private(set) var navigationActionTriggeredPrivate:Bool = false
|
|
|
|
@objc static func listen(
|
|
controller: GCController,
|
|
swapABXY: Bool,
|
|
handler: @escaping (_ elementDict: NSDictionary,
|
|
_ gamepad: GCExtendedGamepad,
|
|
_ element: GCControllerElement) -> Void
|
|
) {
|
|
guard let gamepad = controller.extendedGamepad else { return }
|
|
|
|
// 内部生成 map
|
|
var tempMap: [NSNumber: GCControllerElement] = [:]
|
|
let swiftMap = buildMapping(for: controller, swapABXY: swapABXY)
|
|
for (button, input) in swiftMap {
|
|
tempMap[NSNumber(value: button.rawValue)] = input
|
|
}
|
|
let elementDict = tempMap as NSDictionary
|
|
|
|
// 单一 gamepad.valueChangedHandler
|
|
|
|
gamepad.valueChangedHandler = { gamepad, element in
|
|
handler(elementDict, gamepad, element)
|
|
if #available(iOS 13.0, *) {
|
|
// print("controller.playerIndex \(controller.playerIndex)")
|
|
if controller.playerIndex == .index1 {
|
|
GamepadOverlayStateCenter.shared.publish(snapshot: GamepadOverlaySnapshot(gamepad: gamepad))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 构建按钮映射
|
|
static func buildMapping(for controller: GCController, swapABXY:Bool)
|
|
-> [ControllerElement : GCControllerElement]
|
|
{
|
|
var result: [ControllerElement : GCControllerElement] = [:]
|
|
ControllerUtil.swapABXY = swapABXY
|
|
|
|
if let pad = controller.extendedGamepad {
|
|
// Face buttons
|
|
if swapABXY {
|
|
result[.a] = pad.buttonB
|
|
result[.b] = pad.buttonA
|
|
result[.x] = pad.buttonY
|
|
result[.y] = pad.buttonX
|
|
}
|
|
else{
|
|
result[.a] = pad.buttonA
|
|
result[.b] = pad.buttonB
|
|
result[.x] = pad.buttonX
|
|
result[.y] = pad.buttonY
|
|
}
|
|
|
|
// Shoulders & triggers
|
|
result[.leftShoulder] = pad.leftShoulder
|
|
result[.rightShoulder] = pad.rightShoulder
|
|
result[.leftTrigger] = pad.leftTrigger
|
|
result[.rightTrigger] = pad.rightTrigger
|
|
|
|
// Stick buttons
|
|
if #available(iOS 12.1, *) {
|
|
result[.leftStickButton] = pad.leftThumbstickButton
|
|
result[.rightStickButton] = pad.rightThumbstickButton
|
|
}
|
|
|
|
// DPad
|
|
result[.dpadUp] = pad.dpad.up
|
|
result[.dpadDown] = pad.dpad.down
|
|
result[.dpadLeft] = pad.dpad.left
|
|
result[.dpadRight] = pad.dpad.right
|
|
|
|
// Sticks
|
|
result[.leftStick] = pad.leftThumbstick
|
|
result[.leftStickX] = pad.leftThumbstick.xAxis
|
|
result[.leftStickY] = pad.leftThumbstick.yAxis
|
|
result[.rightStick] = pad.rightThumbstick
|
|
result[.rightStickX] = pad.rightThumbstick.xAxis
|
|
result[.rightStickY] = pad.rightThumbstick.yAxis
|
|
|
|
|
|
// Menu / Options / Share
|
|
if #available(iOS 13.0, *) {
|
|
result[.start] = pad.buttonMenu
|
|
if let options = pad.buttonOptions { result[.select] = options }
|
|
}
|
|
|
|
if #available(iOS 14.0, tvOS 14.0, *) {
|
|
if let home = pad.buttonHome {
|
|
result[.special] = home
|
|
}
|
|
|
|
if let controller = pad.controller {
|
|
let profile = controller.physicalInputProfile
|
|
if let paddle1 = profile.buttons[GCInputXboxPaddleOne] {
|
|
result[.paddle1] = paddle1
|
|
}
|
|
if let paddle2 = profile.buttons[GCInputXboxPaddleTwo] {
|
|
result[.paddle2] = paddle2
|
|
}
|
|
if let paddle3 = profile.buttons[GCInputXboxPaddleThree] {
|
|
result[.paddle3] = paddle3
|
|
}
|
|
if let paddle4 = profile.buttons[GCInputXboxPaddleFour] {
|
|
result[.paddle4] = paddle4
|
|
}
|
|
if let touchpadBtn = profile.buttons[GCInputDualShockTouchpadButton] {
|
|
result[.touchpadButton] = touchpadBtn
|
|
}
|
|
if #available(iOS 15.0, tvOS 15.0, *) {
|
|
if let share = profile.buttons[GCInputButtonShare] {
|
|
result[.misc] = share
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if controller === primaryGCController {ControllerUtil.primaryControllerElementMap = result}
|
|
|
|
return result
|
|
}
|
|
|
|
@objc static var activeStreamingGCControllers:NSMutableSet = NSMutableSet()
|
|
|
|
@objc static var swapABXY:Bool = false
|
|
|
|
@objc static var gamepadArrivalReported: Bool = false
|
|
|
|
@objc static func string(for element: ControllerElement) -> String {
|
|
return element.displayName
|
|
}
|
|
|
|
@objc static func position(for element: ControllerElement) -> ControllerElementPosition {
|
|
return element.position
|
|
}
|
|
|
|
|
|
// MARK: - primary controller section
|
|
|
|
private static var connectObserver: NSObjectProtocol?
|
|
private static var disconnectObserver: NSObjectProtocol?
|
|
|
|
@objc static weak var primaryGCController: GCController?
|
|
static var primaryControllerElementMap: [ControllerElement : GCControllerElement]?
|
|
private static var primaryControllerAxisStates: [ControllerElement : ComparisonResult] = [:]
|
|
|
|
@objc static func installControllerObserversIfNeeded() {
|
|
guard connectObserver == nil, disconnectObserver == nil else { return }
|
|
|
|
if GCController.controllers().first(where: { $0.extendedGamepad != nil }) != nil {
|
|
preparePrimaryController()
|
|
}
|
|
|
|
connectObserver = NotificationCenter.default.addObserver(
|
|
forName: .GCControllerDidConnect,
|
|
object: nil,
|
|
queue: .main
|
|
) { notification in
|
|
guard (notification.object as? GCController)?.extendedGamepad != nil else { return }
|
|
preparePrimaryController()
|
|
}
|
|
|
|
disconnectObserver = NotificationCenter.default.addObserver(
|
|
forName: .GCControllerDidDisconnect,
|
|
object: nil,
|
|
queue: .main
|
|
) { notification in
|
|
preparePrimaryController()
|
|
}
|
|
}
|
|
|
|
@objc static func removeControllerObservers() {
|
|
if let connectObserver {
|
|
NotificationCenter.default.removeObserver(connectObserver)
|
|
self.connectObserver = nil
|
|
}
|
|
|
|
if let disconnectObserver {
|
|
NotificationCenter.default.removeObserver(disconnectObserver)
|
|
self.disconnectObserver = nil
|
|
}
|
|
}
|
|
|
|
@objc static func disableSysGestures(_ controller: GCController?){
|
|
if let controller = controller {
|
|
if #available(iOS 14.0, *) {
|
|
for element in controller.physicalInputProfile.allElements {
|
|
element.preferredSystemGestureState = .disabled
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func preparePrimaryController() {
|
|
guard let controller = GCController.controllers().first(where: { $0.extendedGamepad != nil }) else {
|
|
stopListeningPrimaryController()
|
|
ControllerUtil.primaryGCController = nil
|
|
if #available(iOS 13.0, *) {
|
|
ControllerNavigator.stop()
|
|
}
|
|
return
|
|
}
|
|
|
|
guard GCController.controllers().count == 1 else { return }
|
|
|
|
if let mainFrameVC = delegate as? MainFrameViewController {
|
|
let vc = mainFrameVC.isStreaming() ? StreamFrameViewController.sharedInstance() : mainFrameVC
|
|
GenericUtils.handleFirstGamepadConnection(in: vc) {
|
|
setGCControllerToPrimary(controller)
|
|
return
|
|
}
|
|
}
|
|
|
|
setGCControllerToPrimary(controller)
|
|
}
|
|
|
|
private static func setGCControllerToPrimary(_ controller: GCController) {
|
|
primaryGCController = controller
|
|
_ = buildMapping(for: controller, swapABXY: false)
|
|
disableSysGestures(controller)
|
|
|
|
if #available(iOS 13.0, *) {
|
|
if ControllerNavigator.enabled {
|
|
ControllerNavigator.start()
|
|
GamepadNavigationIllustrationHud.updateNavigationElements(ControllerNavigator.uiNavigationDelegate?.getNavigationElements() ?? [])
|
|
ControllerNavigator.restoreUINavigationHighlight()
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc static func stopListeningPrimaryController(stopListenToRadialMenuButton: Bool = false) {
|
|
guard let primaryControllerButtonMap = primaryControllerElementMap else { return }
|
|
for gcElement in primaryControllerButtonMap.values {
|
|
if let gcButton = gcElement as? GCControllerButtonInput {
|
|
if #available(iOS 13.0, *) {
|
|
if !stopListenToRadialMenuButton, gcButton === primaryControllerButtonMap[ControllerNavigator.radialMenuButton] as? GCControllerButtonInput {continue}
|
|
}
|
|
gcButton.pressedChangedHandler = nil
|
|
}
|
|
if let gcStick = gcElement as? GCControllerDirectionPad {
|
|
gcStick.valueChangedHandler = nil
|
|
}
|
|
if let gcAxis = gcElement as? GCControllerAxisInput {
|
|
gcAxis.valueChangedHandler = nil
|
|
}
|
|
}
|
|
primaryControllerAxisStates.removeAll()
|
|
primaryGCController?.extendedGamepad?.valueChangedHandler = nil
|
|
}
|
|
|
|
@objc static func listenPrimaryControllerButton(_ button: ControllerElement, ignoreSwapABXY: Bool = true, handler: @escaping (_ pressed: Bool) -> Void){
|
|
if var elementMap = ControllerUtil.primaryControllerElementMap {
|
|
if ignoreSwapABXY {
|
|
elementMap[.a] = ControllerUtil.primaryGCController?.extendedGamepad?.buttonA
|
|
elementMap[.b] = ControllerUtil.primaryGCController?.extendedGamepad?.buttonB
|
|
elementMap[.x] = ControllerUtil.primaryGCController?.extendedGamepad?.buttonX
|
|
elementMap[.y] = ControllerUtil.primaryGCController?.extendedGamepad?.buttonY
|
|
}
|
|
if let gcButton = elementMap[button] as? GCControllerButtonInput {
|
|
gcButton.pressedChangedHandler = { _, _, pressed in
|
|
handler(pressed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc static func listenPrimaryControllerStick(_ stick: ControllerElement, handler: @escaping (_ offsetVector: CGVector) -> Void){
|
|
if let elementMap = ControllerUtil.primaryControllerElementMap {
|
|
if let gcStick = elementMap[stick] as? GCControllerDirectionPad {
|
|
gcStick.valueChangedHandler = { _, xValue, yValue in
|
|
handler(CGVector(dx: CGFloat(xValue), dy: CGFloat(yValue)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc static func listenPrimaryControllerStickAxis(_ stickAxis: ControllerElement, threshold: CGFloat, handler: @escaping (_ state: ComparisonResult) -> Void) {
|
|
guard threshold > 0 else { return }
|
|
guard let elementMap = ControllerUtil.primaryControllerElementMap,
|
|
let gcAxis = elementMap[stickAxis] as? GCControllerAxisInput else { return }
|
|
|
|
primaryControllerAxisStates[stickAxis] = stickAxisState(for: CGFloat(gcAxis.value), threshold: threshold)
|
|
gcAxis.valueChangedHandler = { _, value in
|
|
let newState = stickAxisState(for: CGFloat(value), threshold: threshold)
|
|
let previousState = primaryControllerAxisStates[stickAxis] ?? .orderedSame
|
|
guard newState != previousState else { return }
|
|
|
|
primaryControllerAxisStates[stickAxis] = newState
|
|
handler(newState)
|
|
}
|
|
}
|
|
|
|
private static func stickAxisState(for value: CGFloat, threshold: CGFloat) -> ComparisonResult {
|
|
if value > threshold {
|
|
return .orderedDescending
|
|
}
|
|
|
|
if value < -threshold {
|
|
return .orderedAscending
|
|
}
|
|
|
|
return .orderedSame
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - input processing
|
|
|
|
@objc static func compensated(offsetVector: CGVector, minOffset: CGFloat, circulate:Bool=false) -> CGVector{
|
|
let vectorHypot = hypot(offsetVector.dx, offsetVector.dy)
|
|
guard vectorHypot > 0 else {return CGVector(dx: 0, dy: 0)}
|
|
let targetHypot = minOffset + (stickMaxOffset-minOffset)*(vectorHypot/stickMaxOffset)
|
|
var compensatedX = targetHypot * (offsetVector.dx/vectorHypot)
|
|
var compensatedY = targetHypot * (offsetVector.dy/vectorHypot)
|
|
|
|
if circulate {
|
|
return circulated(offsetVector: CGVector(dx: compensatedX, dy: compensatedY))
|
|
}
|
|
else {
|
|
compensatedX = max(min(compensatedX, stickMaxOffset),-stickMaxOffset)
|
|
compensatedY = max(min(compensatedY, stickMaxOffset),-stickMaxOffset)
|
|
return CGVector(dx: compensatedX, dy: compensatedY)
|
|
}
|
|
}
|
|
|
|
@objc static func circulated(offsetVector: CGVector) -> CGVector{
|
|
let vectorHypot = hypot(offsetVector.dx, offsetVector.dy)
|
|
guard vectorHypot > 0 else {return CGVector(dx: 0, dy: 0)}
|
|
let targetHypot = min(vectorHypot, stickMaxOffset)
|
|
let circulatedX = targetHypot*(offsetVector.dx/vectorHypot)
|
|
let circulatedY = targetHypot*(offsetVector.dy/vectorHypot)
|
|
return CGVector(dx: circulatedX, dy: circulatedY)
|
|
}
|
|
}
|
|
|
|
#if os(iOS) && !VOIDLINK_PREVIEW
|
|
@available(iOS 14.0, *)
|
|
private final class DualSenseHapticsEQStore: ObservableObject {
|
|
let originalValues: [Double]
|
|
@Published private(set) var values: [Double]
|
|
|
|
init(values: [Float]) {
|
|
let doubles = values.map(Double.init)
|
|
originalValues = doubles
|
|
self.values = doubles
|
|
}
|
|
|
|
func binding(for index: Int) -> Binding<Double> {
|
|
Binding(
|
|
get: { self.values[index] },
|
|
set: { self.updateValue($0, at: index) }
|
|
)
|
|
}
|
|
|
|
func reset() {
|
|
values = ControllerUtil.defaultDualSenseHapticsEQSnapshot().map(Double.init)
|
|
preview()
|
|
}
|
|
|
|
func save() {
|
|
ControllerUtil.saveDualSenseHapticsEQ(values.map(Float.init))
|
|
}
|
|
|
|
func cancel() {
|
|
ControllerUtil.previewDualSenseHapticsEQ(originalValues.map(Float.init))
|
|
}
|
|
|
|
private func updateValue(_ value: Double, at index: Int) {
|
|
values[index] = value
|
|
preview()
|
|
}
|
|
|
|
private func preview() {
|
|
ControllerUtil.previewDualSenseHapticsEQ(values.map(Float.init))
|
|
}
|
|
}
|
|
|
|
@available(iOS 14.0, *)
|
|
private struct DualSenseHapticsEQView: View {
|
|
private struct Parameter: Identifiable {
|
|
let id: Int
|
|
let title: String
|
|
let range: ClosedRange<Double>
|
|
let step: Double
|
|
let usesMultiplier: Bool
|
|
}
|
|
|
|
@ObservedObject var store: DualSenseHapticsEQStore
|
|
let onClose: () -> Void
|
|
|
|
private let parameters = [
|
|
Parameter(id: 0, title: "Master Gain", range: 0...1, step: 0.01, usesMultiplier: false),
|
|
Parameter(id: 1, title: "Low Band (<200 Hz)", range: 0...2, step: 0.01, usesMultiplier: true),
|
|
Parameter(id: 2, title: "High Band (>200 Hz)", range: 0...2, step: 0.01, usesMultiplier: true),
|
|
Parameter(id: 3, title: "Transient Gain", range: 0...1, step: 0.01, usesMultiplier: false),
|
|
Parameter(id: 4, title: "Sharpness", range: 0...2, step: 0.01, usesMultiplier: true),
|
|
]
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
Color.black.opacity(0.28)
|
|
.edgesIgnoringSafeArea(.all)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { cancelAndClose() }
|
|
|
|
VStack(spacing: 12) {
|
|
HStack(spacing: 10) {
|
|
Text("DualSense Haptic EQ")
|
|
.font(.system(size: 19, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.black.opacity(0.76))
|
|
|
|
Spacer()
|
|
|
|
Button(action: store.reset) {
|
|
Image(systemName: "arrow.counterclockwise")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(Color.black.opacity(0.62))
|
|
.frame(width: 34, height: 34)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(Color.white.opacity(0.9))
|
|
)
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
.accessibility(label: Text("Reset EQ"))
|
|
}
|
|
|
|
ScrollView(showsIndicators: false) {
|
|
VStack(spacing: 8) {
|
|
ForEach(parameters) { parameter in
|
|
parameterRow(parameter)
|
|
}
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
actionButton(title: "Cancel", isPrimary: false) {
|
|
cancelAndClose()
|
|
}
|
|
actionButton(title: "Save", isPrimary: true) {
|
|
store.save()
|
|
onClose()
|
|
}
|
|
}
|
|
}
|
|
.padding(UIDevice.current.userInterfaceIdiom == .phone ? 12 : 18)
|
|
.frame(maxWidth: UIDevice.current.userInterfaceIdiom == .phone ? 380 : 460)
|
|
.frame(maxHeight: max(280, geometry.size.height - 28))
|
|
.background(
|
|
RoundedRectangle(cornerRadius: UIDevice.current.userInterfaceIdiom == .phone ? 22 : 28, style: .continuous)
|
|
.fill(Color(red: 0.94, green: 0.97, blue: 0.98).opacity(0.98))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: UIDevice.current.userInterfaceIdiom == .phone ? 22 : 28, style: .continuous)
|
|
.stroke(Color.white.opacity(0.96), lineWidth: 1.1)
|
|
)
|
|
)
|
|
.shadow(color: Color.black.opacity(0.12), radius: 24, x: 0, y: 12)
|
|
.padding(.horizontal, 14)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func parameterRow(_ parameter: Parameter) -> some View {
|
|
HStack(spacing: 10) {
|
|
Text(parameter.title)
|
|
.font(.system(size: 12.5, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.black.opacity(0.64))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
.frame(width: 132, alignment: .leading)
|
|
|
|
Slider(
|
|
value: store.binding(for: parameter.id),
|
|
in: parameter.range,
|
|
step: parameter.step
|
|
)
|
|
.accentColor(Color(red: 0.18, green: 0.61, blue: 0.67))
|
|
.environment(\.colorScheme, .light)
|
|
|
|
Text(formattedValue(store.values[parameter.id], multiplier: parameter.usesMultiplier))
|
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
|
.foregroundColor(Color(red: 0.14, green: 0.46, blue: 0.52))
|
|
.frame(width: 48, alignment: .trailing)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.frame(height: 44)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color.white.opacity(0.9))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.stroke(Color.white.opacity(0.94), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func actionButton(title: String, isPrimary: Bool, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Text(title)
|
|
.font(.system(size: 14, weight: .bold, design: .rounded))
|
|
.foregroundColor(isPrimary ? .white : Color.black.opacity(0.66))
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 38)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(isPrimary
|
|
? Color(red: 0.16, green: 0.53, blue: 0.59)
|
|
: Color.white.opacity(0.82))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.stroke(Color.white.opacity(isPrimary ? 0.22 : 0.76), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(PlainButtonStyle())
|
|
}
|
|
|
|
private func formattedValue(_ value: Double, multiplier: Bool) -> String {
|
|
String(format: multiplier ? "x%.2f" : "%.2f", value)
|
|
}
|
|
|
|
private func cancelAndClose() {
|
|
store.cancel()
|
|
onClose()
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - realtime overlay
|
|
|
|
@available(iOS 13.0, *)
|
|
struct GamepadOverlaySnapshot: Equatable {
|
|
var pressedButtons: Set<ControllerElement> = []
|
|
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<ControllerElement>()
|
|
|
|
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(.start) }
|
|
if gamepad.buttonOptions?.isPressed == true { pressedButtons.insert(.select) }
|
|
if #available(iOS 14.0, *), gamepad.buttonHome?.isPressed == true { pressedButtons.insert(.special) }
|
|
if gamepad.leftThumbstickButton?.isPressed == true { pressedButtons.insert(.leftStickButton) }
|
|
if gamepad.rightThumbstickButton?.isPressed == true { pressedButtons.insert(.rightStickButton) }
|
|
|
|
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)
|
|
}
|
|
}
|