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>
This commit is contained in:
2026-07-02 11:05:10 +02:00
parent e925d00194
commit 133e25849d
84 changed files with 4231 additions and 2698 deletions
@@ -0,0 +1,178 @@
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for
// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus
// cursor, controller polling, haptics, and keeping the focused row scrolled into view.
//
// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
// first-class fallback tapping a row focuses AND activates it (rows are always fully visible, so
// the carousel's "first tap re-centers" step would only add friction here), and free finger
// scrolling is never hijacked back to the focused row until the next controller move.
//
// Feedback is dual-channel like the carousel: `.sensoryFeedback` ticks the DEVICE Taptic engine,
// `MenuHaptics` ticks the CONTROLLER. Moves and value changes get the crisp detent; a refused
// move at either end gets the dull boundary thud plus a short vertical recoil.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
let items: [Item]
/// Output only: the list WRITES the focused item's id here (e.g. for a caller's hint bar).
@Binding var focusID: Item.ID?
/// Left/right on the focused row. Return whether the value actually changed true plays the
/// move detent, false the boundary thud (end of a clamped range, or nothing to adjust).
var onAdjust: ((Item, Int) -> Bool)?
/// A activate the focused row (toggle it, open it, run it the caller decides).
let onActivate: (Item) -> Void
/// B back/dismiss; nil disables it.
var onBack: (() -> Void)?
/// Whether this list currently owns controller input same handoff contract as
/// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad).
var isActive: Bool = true
@ViewBuilder let row: (Item, _ focused: Bool) -> Row
@State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared)
/// Authoritative focus cursor (index into `items`).
@State private var cursor = 0
/// A short vertical recoil when a move is refused at a list end.
@State private var bumpOffset: CGFloat = 0
/// `.sensoryFeedback` counters (see GamepadCarousel): device ticks for activate / value-change
/// / end-stop events; moves trigger on `cursor` itself.
@State private var activateTick = 0
@State private var adjustTick = 0
@State private var boundaryTick = 0
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical) {
LazyVStack(spacing: 6) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
row(item, idx == cursor && isActive)
.contentShape(Rectangle())
.onTapGesture { tap(idx) }
.id(item.id)
}
}
.padding(.vertical, 10)
}
// .never, not .hidden macOS's "always show scroll bars" setting overrides .hidden.
.scrollIndicators(.never)
.offset(y: bumpOffset)
.onChange(of: cursor) { _, newValue in
guard newValue >= 0, newValue < items.count else { return }
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(items[newValue].id)
}
}
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.selection, trigger: adjustTick)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
.onAppear {
reconcile()
wire()
if isActive { input.start() }
}
.onDisappear {
input.stop()
haptics.stop()
}
.onChange(of: isActive) { _, active in
if active {
wire()
input.start()
} else {
input.stop()
haptics.stop()
}
}
// Re-seed a dropped focus AND re-wire the input callbacks so they capture the current
// `items` value (a plain array it would otherwise go stale in the stored closures).
.onChange(of: items.map(\.id)) { _, _ in
reconcile()
wire()
}
}
// MARK: - Input wiring
private func wire() {
input.onMove = { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
case .left: adjust(by: -1)
case .right: adjust(by: 1)
}
}
input.onConfirm = { activate() }
input.onBack = onBack
}
private func step(by delta: Int) {
guard !items.isEmpty else { return }
let target = cursor + delta
guard target >= 0, target < items.count else { return boundaryBump(forward: delta > 0) }
cursor = target
focusID = items[target].id
haptics.move()
}
private func adjust(by delta: Int) {
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
if onAdjust(items[cursor], delta) {
adjustTick &+= 1
haptics.move()
} else {
boundaryTick &+= 1
haptics.boundary()
}
}
private func activate() {
guard cursor >= 0, cursor < items.count else { return }
activateTick &+= 1
haptics.confirm()
onActivate(items[cursor])
}
/// Touch fallback: a tap focuses the row and activates it in one go.
private func tap(_ idx: Int) {
guard idx >= 0, idx < items.count else { return }
if cursor != idx {
cursor = idx
focusID = items[idx].id
}
activate()
}
/// Keep `cursor`/`focusID` consistent with `items`: seed on appear; on a list change keep the
/// same focused item when it survives, else clamp the cursor into range.
private func reconcile() {
guard !items.isEmpty else {
cursor = 0
if focusID != nil { focusID = nil }
return
}
if let id = focusID, let idx = items.firstIndex(where: { $0.id == id }) {
cursor = idx
} else {
cursor = min(max(cursor, 0), items.count - 1)
focusID = items[cursor].id
}
}
private func boundaryBump(forward: Bool) {
boundaryTick &+= 1
haptics.boundary()
let recoil: CGFloat = forward ? -14 : 14
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
}
}
#endif