Files
punktfunk/clients/apple/Sources/PunktfunkKit/Gamepad/MenuHaptics.swift
T
enricobuehler 133e25849d feat(apple): gamepad UI v2 — controller settings + add host, aurora, macOS
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit:
Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split
along the same seams.

The gamepad mode is couch-complete, and now on macOS too (the living-room
Mac case), not just iOS/iPadOS:

- GamepadSettingsView: a console-style, fully controller-navigable settings
  screen (X from the launcher) — up/down moves focus, left/right steps values
  (clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a
  one-line description. Backed by GamepadMenuList, the vertical sibling of
  GamepadCarousel, and SettingsOptions — the option lists hoisted out of
  SettingsView statics and shared by the touch, tvOS and gamepad settings.
- GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad
  — field rows open an on-screen controller keyboard (dpad grid, A types,
  X backspaces, B done); the launcher carousel ends in an Add Host tile, so
  the dead-end "add one with touch first" empty state is gone.
- Launcher polish: contextual hint bar with the pad's real button glyphs,
  controller name + battery chip, one shared console chrome.
- GamepadScreenBackground: an animated aurora (TimelineView-driven drifting
  blobs in the brand's violet family, breathing radii, slow hue shift,
  legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a
  .metal library only bundles reliably in one of the two build systems (SPM vs
  the xcodeproj's synced folders) these sources compile under.
- macOS port: settings/add-host/library present as sized sheets (a macOS sheet
  takes its content's IDEAL size, and the GeometryReader-driven screens
  collapsed to nothing), NSScreen-based mode lists, scroll indicators .never
  (the "always show scroll bars" setting overrides .hidden), tray scrims so
  scrolled rows dim under the pinned title/hints, extra title clearance, and a
  PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/
  library render-verified live on a real Mac + LAN hosts.
- GamepadMenuInput: X button support, and (re)start now snapshots held buttons
  so a controller handoff press never fires twice (the B that closed the
  keyboard no longer also cancels the screen underneath).
- Cleanups: one "Connection failed" alert in ContentView instead of one per
  home screen; HostDiscovery.advertises/unsaved shared by both home screens.
- host: can_encode_444 stub for the non-Linux/Windows host build (the macOS
  synthetic-source loopback used by the Swift tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:24:44 +02:00

120 lines
5.5 KiB
Swift

// 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
}
}