Files
punktfunk/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.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

179 lines
8.3 KiB
Swift

// Explicit left-stick/dpad-driven menu navigation for the gamepad UI's host carousel and library
// coverflow (iOS/iPadOS only see GamepadUIEnvironment).
//
// Polls the active controller at 60 Hz rather than installing `valueChangedHandler`/
// `pressedChangedHandler` callbacks mirroring `ControllerTestView`'s "Input" card (see its own
// comment: "Poll the live controller ... no handlers installed"), the one thing in this codebase
// already confirmed on real hardware to read a controller reliably outside a streaming session. Two
// earlier versions of this class both installed handlers directly (first reading the dpad's combined
// `.xAxis`/`.yAxis`, then its discrete `.isPressed` states, matching `GamepadCapture`'s pattern) and
// neither one's callbacks fired on-device even though the SAME controller's input showed up correctly
// in `ControllerTestView`'s poll-based readout so polling isn't just a style choice here, it's the
// only approach confirmed to actually work outside a stream. Being read-only, it also can't conflict
// with `GamepadCapture` installing its own handlers once a stream starts there's nothing to hand
// off or race over.
//
// The button set mirrors a console launcher: A confirms, B backs out, Y is a screen's secondary
// action, X a tertiary one, and the shoulders (L1/R1) are optional fast "jump" steps. Directional
// moves auto-repeat on a held stick/dpad after an initial delay; every button is edge-triggered
// (fires once per press).
import Foundation
import GameController
@MainActor
public final class GamepadMenuInput {
public enum Direction: Equatable, Sendable {
case up, down, left, right
}
private let manager: GamepadManager
private var pollTimer: Timer?
private var isActive = false
/// Seed the pressed-state trackers from the LIVE controller on the first poll after a
/// (re)start, firing nothing. Screens hand the controller off (a keyboard closes, a cover
/// dismisses) while the user is still holding the very button that triggered the handoff
/// without this, the next screen's first poll would read that held button as a fresh edge
/// and act on the same press twice (e.g. the B that closed the keyboard also backing out
/// of the screen underneath).
private var needsSnapshot = false
private var currentDirection: Direction?
private var repeatTimer: Timer?
private var wasConfirmPressed = false
private var wasSecondaryPressed = false
private var wasTertiaryPressed = false
private var wasBackPressed = false
private var wasLeftShoulderPressed = false
private var wasRightShoulderPressed = false
/// Discrete directional move already debounced (fires once on a fresh press, then repeats
/// on a hold after an initial delay, like a standard menu).
public var onMove: ((Direction) -> Void)?
/// Button A (or equivalent primary action) edge-triggered, fires once per press.
public var onConfirm: (() -> Void)?
/// Button Y (or equivalent secondary action, e.g. "open library") edge-triggered.
public var onSecondary: (() -> Void)?
/// Button X (or equivalent tertiary action, e.g. "settings" / "delete") edge-triggered.
public var onTertiary: (() -> Void)?
/// Button B (or equivalent back/dismiss) edge-triggered.
public var onBack: (() -> Void)?
/// Shoulder buttons (L1 `false` / R1 `true`) edge-triggered fast-jump steps, optional per
/// screen. Unset the shoulders do nothing.
public var onShoulder: ((Bool) -> Void)?
/// Stick magnitude below this reads as neutral (dead zone).
private let deadzone: Float = 0.5
private let initialRepeatDelay: TimeInterval = 0.38
private let repeatInterval: TimeInterval = 0.16
private let pollInterval: TimeInterval = 1.0 / 60.0
public init(manager: GamepadManager) {
self.manager = manager
}
public func start() {
guard !isActive else { return }
isActive = true
needsSnapshot = true
let timer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.poll() }
}
RunLoop.main.add(timer, forMode: .common)
pollTimer = timer
}
public func stop() {
isActive = false
pollTimer?.invalidate()
pollTimer = nil
repeatTimer?.invalidate()
repeatTimer = nil
currentDirection = nil
wasConfirmPressed = false
wasSecondaryPressed = false
wasTertiaryPressed = false
wasBackPressed = false
wasLeftShoulderPressed = false
wasRightShoulderPressed = false
}
/// Reads `manager.active` fresh every tick (no persistent binding to a specific controller
/// needed) a disconnect/reconnect or a controller switch is just picked up on the next poll.
private func poll() {
guard isActive, let gamepad = manager.active?.controller.extendedGamepad else { return }
if needsSnapshot {
// Adopt whatever is held right now without firing (see `needsSnapshot`): a button
// must be RELEASED after a handoff before it can act here, and a held direction only
// keeps moving once it changes or re-engages.
needsSnapshot = false
wasConfirmPressed = gamepad.buttonA.isPressed
wasSecondaryPressed = gamepad.buttonY.isPressed
wasTertiaryPressed = gamepad.buttonX.isPressed
wasBackPressed = gamepad.buttonB.isPressed
wasLeftShoulderPressed = gamepad.leftShoulder.isPressed
wasRightShoulderPressed = gamepad.rightShoulder.isPressed
currentDirection = directionFrom(gamepad)
return
}
edge(gamepad.buttonA.isPressed, &wasConfirmPressed) { onConfirm?() }
edge(gamepad.buttonY.isPressed, &wasSecondaryPressed) { onSecondary?() }
edge(gamepad.buttonX.isPressed, &wasTertiaryPressed) { onTertiary?() }
edge(gamepad.buttonB.isPressed, &wasBackPressed) { onBack?() }
edge(gamepad.leftShoulder.isPressed, &wasLeftShoulderPressed) { onShoulder?(false) }
edge(gamepad.rightShoulder.isPressed, &wasRightShoulderPressed) { onShoulder?(true) }
updateDirection(directionFrom(gamepad))
}
/// Fire `action` on the rising edge of `pressed`, tracking the last state in `was`.
private func edge(_ pressed: Bool, _ was: inout Bool, _ action: () -> Void) {
if pressed, !was { action() }
was = pressed
}
/// The current requested direction: the left stick is the primary/natural input; the dpad is an
/// alternative. Read via discrete `.isPressed` / analog `.value` (never the dpad's combined axis
/// the first version of this class did that and it silently never registered a press on-device).
private func directionFrom(_ gamepad: GCExtendedGamepad) -> Direction? {
let stick = gamepad.leftThumbstick
let x = stick.xAxis.value
let y = stick.yAxis.value
if abs(x) > abs(y), abs(x) > deadzone {
return x > 0 ? .right : .left
} else if abs(y) > deadzone {
return y > 0 ? .up : .down
}
let dpad = gamepad.dpad
if dpad.left.isPressed { return .left }
if dpad.right.isPressed { return .right }
if dpad.up.isPressed { return .up }
if dpad.down.isPressed { return .down }
return nil
}
private func updateDirection(_ direction: Direction?) {
guard direction != currentDirection else { return }
repeatTimer?.invalidate()
repeatTimer = nil
currentDirection = direction
guard let direction else { return }
onMove?(direction)
// First repeat after a longer delay (so a quick tap doesn't double-move), then steady.
let timer = Timer(timeInterval: initialRepeatDelay, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self else { return }
self.repeatTimer?.invalidate()
let repeating = Timer(timeInterval: self.repeatInterval, repeats: true) { [weak self] _ in
Task { @MainActor in self?.onMove?(direction) }
}
RunLoop.main.add(repeating, forMode: .common)
self.repeatTimer = repeating
}
}
RunLoop.main.add(timer, forMode: .common)
repeatTimer = timer
}
}