Files
plezy/tvos/Runner/Plugins/universal_gamepad/GCControllerManager.swift
T
2026-04-24 14:11:24 +02:00

245 lines
9.0 KiB
Swift

import GameController
/// Manages GCController connections and input, translating events into the
/// compact wire-format arrays expected by the Dart side.
class GCControllerManager {
// MARK: - Properties
private let streamHandler: GamepadStreamHandler
/// Maps a GCController instance to its stable int gamepad ID.
private var controllerIds: [ObjectIdentifier: Int] = [:]
/// Counter for generating unique IDs.
private var nextId: Int = 0
/// Notification observers so we can remove them on dispose.
private var connectObserver: NSObjectProtocol?
private var disconnectObserver: NSObjectProtocol?
// MARK: - Init
init(streamHandler: GamepadStreamHandler) {
self.streamHandler = streamHandler
}
// MARK: - Lifecycle
/// Begin listening for GCController connect/disconnect notifications and
/// register input handlers on any controllers that are already connected.
func startObserving() {
connectObserver = NotificationCenter.default.addObserver(
forName: .GCControllerDidConnect,
object: nil,
queue: .main
) { [weak self] notification in
guard let self = self,
let controller = notification.object as? GCController else { return }
self.controllerDidConnect(controller)
}
disconnectObserver = NotificationCenter.default.addObserver(
forName: .GCControllerDidDisconnect,
object: nil,
queue: .main
) { [weak self] notification in
guard let self = self,
let controller = notification.object as? GCController else { return }
self.controllerDidDisconnect(controller)
}
// Handle controllers that were already connected before we started
// observing (e.g. wired controllers).
for controller in GCController.controllers() {
controllerDidConnect(controller)
}
}
/// Remove all observers and clear input handlers.
func stopObserving() {
if let observer = connectObserver {
NotificationCenter.default.removeObserver(observer)
connectObserver = nil
}
if let observer = disconnectObserver {
NotificationCenter.default.removeObserver(observer)
disconnectObserver = nil
}
// Remove value changed handlers from all tracked controllers.
for controller in GCController.controllers() {
controller.extendedGamepad?.valueChangedHandler = nil
}
controllerIds.removeAll()
}
// MARK: - listGamepads
/// Returns an array of dictionaries describing currently connected
/// controllers, suitable for returning over a MethodChannel.
func listGamepads() -> [[String: Any]] {
var result: [[String: Any]] = []
for controller in GCController.controllers() {
let key = ObjectIdentifier(controller)
guard let gamepadId = controllerIds[key] else { continue }
var info: [String: Any] = [
"id": gamepadId,
"name": controllerName(controller),
]
if let vendorId = vendorId(for: controller) {
info["vendorId"] = vendorId
}
if let productId = productId(for: controller) {
info["productId"] = productId
}
result.append(info)
}
return result
}
// MARK: - Connection events
private func controllerDidConnect(_ controller: GCController) {
let gamepadId = assignId(for: controller)
// Register input handlers for the extended gamepad profile.
registerInputHandlers(for: controller, gamepadId: gamepadId)
// Wire format: [0, gamepadId, timestamp, connected, name, vendorId, productId]
let event: [Any] = [
0,
gamepadId,
currentTimestamp(),
true,
controllerName(controller),
vendorId(for: controller) as Any,
productId(for: controller) as Any,
]
streamHandler.send(event: event)
}
private func controllerDidDisconnect(_ controller: GCController) {
let key = ObjectIdentifier(controller)
guard let gamepadId = controllerIds.removeValue(forKey: key) else { return }
controller.extendedGamepad?.valueChangedHandler = nil
// Wire format: [0, gamepadId, timestamp, connected, name, vendorId, productId]
let event: [Any] = [
0,
gamepadId,
currentTimestamp(),
false,
controllerName(controller),
vendorId(for: controller) as Any,
productId(for: controller) as Any,
]
streamHandler.send(event: event)
}
// MARK: - Input handlers
private func registerInputHandlers(for controller: GCController, gamepadId: Int) {
guard let extendedGamepad = controller.extendedGamepad else { return }
extendedGamepad.valueChangedHandler = { [weak self] (gamepad, element) in
guard let self = self else { return }
self.handleValueChanged(gamepad: gamepad, element: element, gamepadId: gamepadId)
}
}
private func handleValueChanged(gamepad: GCExtendedGamepad,
element: GCControllerElement,
gamepadId: Int) {
let timestamp = currentTimestamp()
// D-pad fires as a single GCControllerDirectionPad element, not as
// individual direction buttons. Expand it into 4 button events.
if element === gamepad.dpad {
sendButtonEvent(gamepadId: gamepadId, index: 12,
pressed: gamepad.dpad.up.isPressed,
value: Double(gamepad.dpad.up.value), timestamp: timestamp)
sendButtonEvent(gamepadId: gamepadId, index: 13,
pressed: gamepad.dpad.down.isPressed,
value: Double(gamepad.dpad.down.value), timestamp: timestamp)
sendButtonEvent(gamepadId: gamepadId, index: 14,
pressed: gamepad.dpad.left.isPressed,
value: Double(gamepad.dpad.left.value), timestamp: timestamp)
sendButtonEvent(gamepadId: gamepadId, index: 15,
pressed: gamepad.dpad.right.isPressed,
value: Double(gamepad.dpad.right.value), timestamp: timestamp)
return
}
// Check buttons.
if let (index, button) = ButtonMapping.buttonIndex(for: element, in: gamepad) {
sendButtonEvent(gamepadId: gamepadId, index: index,
pressed: button.isPressed,
value: Double(button.value), timestamp: timestamp)
return
}
// Check axes (may produce multiple events when a full thumbstick fires).
let axisEvents = ButtonMapping.axisIndices(for: element, in: gamepad)
for (index, axisValue) in axisEvents {
// Wire format: [2, gamepadId, timestamp, axisIndex, value]
streamHandler.send(event: [2, gamepadId, timestamp, index, axisValue])
}
}
private func sendButtonEvent(gamepadId: Int, index: Int,
pressed: Bool, value: Double, timestamp: Int) {
// Wire format: [1, gamepadId, timestamp, buttonIndex, pressed, value]
streamHandler.send(event: [1, gamepadId, timestamp, index, pressed, value])
}
// MARK: - Helpers
private func assignId(for controller: GCController) -> Int {
let key = ObjectIdentifier(controller)
if let existing = controllerIds[key] {
return existing
}
let id = nextId
nextId += 1
controllerIds[key] = id
return id
}
private func controllerName(_ controller: GCController) -> String {
let category = controller.productCategory
if !category.isEmpty {
return category
}
return controller.vendorName ?? "Unknown Controller"
}
/// Returns the USB vendor ID for the controller, if available.
///
/// GCController does not directly expose numeric vendor/product IDs
/// through the public API. We attempt to obtain them from the
/// underlying physical device description on iOS 16+.
private func vendorId(for controller: GCController) -> Int? {
// GCController does not publicly expose numeric vendor IDs on iOS.
// Return nil; the Dart side treats this as optional.
return nil
}
/// Returns the USB product ID for the controller, if available.
private func productId(for controller: GCController) -> Int? {
// GCController does not publicly expose numeric product IDs on iOS.
// Return nil; the Dart side treats this as optional.
return nil
}
/// Returns the current timestamp in milliseconds since epoch.
private func currentTimestamp() -> Int {
return Int(Date().timeIntervalSince1970 * 1000)
}
}