// Controller-side haptic feedback for the gamepad menu UI (the host launcher + the library // coverflow). The couch case is the whole point: the user is holding a game controller, not the // iPhone/iPad, so a device-only `.sensoryFeedback` tick never reaches their hands — this plays a // short CoreHaptics transient on the ACTIVE controller instead, so a dpad move / launch / end-stop // is felt on the pad. (The views pair this with `.sensoryFeedback` so a touch/handheld user still // gets the device Taptic tick; the two are independent channels, and both firing is intended.) // // This is menu-only — it never runs during a stream (the session's own GamepadFeedback owns the // controller then), so there's no contention over the pad's haptic engine. Like GamepadMenuInput, // it reads `GamepadManager.shared.active` fresh and rebuilds its engine when the controller // changes, so a hot-swapped pad just starts buzzing on the next tick. Everything is best-effort: // a pad with no haptics (many Xbox pads on iOS, a Siri Remote) silently no-ops. import CoreHaptics import Foundation import GameController @MainActor public final class MenuHaptics { private let manager: GamepadManager /// The engine for the controller it was built against — dropped and rebuilt when `active` /// changes (identity compare) or after a stop/reset handler fires. private var engine: CHHapticEngine? private weak var boundController: GCController? public init(manager: GamepadManager) { self.manager = manager } /// A light, crisp detent — one per menu step. Deliberately tiny so a held direction repeating /// at ~5 Hz reads as a smooth ratchet rather than a jackhammer. public func move() { play(intensity: 0.45, sharpness: 0.75, duration: 0.02) } /// A fuller, rounder pulse on confirm/launch — the "you did the thing" thunk. public func confirm() { play(intensity: 1.0, sharpness: 0.55, duration: 0.055) } /// A soft, dull bump when a move is refused at the end of a non-wrapping list — low sharpness so /// it feels like hitting a wall, distinct from the crisp `move()` detent. public func boundary() { play(intensity: 0.7, sharpness: 0.18, duration: 0.06) } /// Release the engine and forget the controller — call on the menu screen's disappear so the /// pad's haptic engine isn't held open while streaming or on the touch UI. public func stop() { engine?.stop(completionHandler: nil) engine = nil boundController = nil } /// Fire a single transient. Rebuilds the engine against the current active controller if it /// changed; swallows every failure (a pad without a haptics engine, a transient XPC hiccup) — /// menu haptics are a nicety, never a correctness path. private func play(intensity: Float, sharpness: Float, duration: TimeInterval) { guard let controller = manager.active?.controller else { // No pad (or a non-forwardable one): nothing to buzz. Drop any stale engine. if boundController != nil { stop() } return } guard let engine = engine(for: controller) else { return } let event = CHHapticEvent( eventType: .hapticTransient, parameters: [ CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness), ], relativeTime: 0, duration: duration) do { let player = try engine.makePlayer(with: CHHapticPattern(events: [event], parameters: [])) try player.start(atTime: CHHapticTimeImmediate) } catch { // The engine went stale between builds (stopped/reset). Drop it; the next tick rebuilds. self.engine = nil boundController = nil } } /// The started engine for `controller`, (re)built on first use or after a controller swap. private func engine(for controller: GCController) -> CHHapticEngine? { if let engine, boundController === controller { return engine } engine?.stop(completionHandler: nil) engine = nil boundController = nil guard let built = controller.haptics?.createEngine(withLocality: .default) else { return nil } // Menu ticks carry no audio — keep the engine out of the app's audio session (the same // discipline the session RumbleRenderer uses). built.playsHapticsOnly = true // The haptic server can pull the engine out from under us (backgrounding, an audio // interruption, a controller drop); drop our reference so the next tick lazily rebuilds // rather than throwing forever. built.stoppedHandler = { [weak self] _ in Task { @MainActor in self?.dropEngine(if: controller) } } built.resetHandler = { [weak self] in Task { @MainActor in self?.dropEngine(if: controller) } } do { try built.start() } catch { return nil } engine = built boundController = controller return built } /// Drop the cached engine only if it's still the one for `controller` — a handler firing after a /// swap must not clobber the freshly built engine for the new pad. private func dropEngine(if controller: GCController) { guard boundController === controller else { return } engine = nil boundController = nil } }