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:
@@ -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
|
||||
Reference in New Issue
Block a user