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,94 @@
|
||||
// Steers the system's iPad pointer-lock resolution down to a chosen "anchor" view controller.
|
||||
//
|
||||
// `UIViewController.prefersPointerLocked` is resolved the same way as the status bar: the system
|
||||
// walks DOWN from the window's root view controller through `childViewControllerForPointerLock`.
|
||||
// SwiftUI's hosting / container view controllers do NOT forward that query to their children, so a
|
||||
// `UIViewControllerRepresentable` controller buried in the SwiftUI tree (our StreamViewController)
|
||||
// is never consulted — its `prefersPointerLocked = true` is silently ignored and a Magic Keyboard
|
||||
// trackpad / mouse falls through to the absolute-pointer path instead of being captured.
|
||||
//
|
||||
// Swizzling the DEFAULT implementation isn't enough: the controllers that break the chain
|
||||
// (UIHostingController and SwiftUI's internal containers) provide their OWN implementation of the
|
||||
// property, so a base-class swizzle never runs for them. Instead we walk UP the LIVE `parent`
|
||||
// chain from the anchor to the window root and, on each real ancestor, force
|
||||
// `childViewControllerForPointerLock` to return the next controller toward the anchor. Each forced
|
||||
// value is a genuine direct child (we follow the actual containment chain), so the system's
|
||||
// downward walk reaches the anchor and reads its `prefersPointerLocked`.
|
||||
//
|
||||
// The forcing is per-INSTANCE — an associated object — gated behind a one-time per-CLASS IMP
|
||||
// swizzle. Only the specific controllers in the anchor's chain are affected; every other instance
|
||||
// of those classes keeps its original behavior (associated object nil → original IMP). The forced
|
||||
// values are cleared on disengage so the long-lived SwiftUI parents don't retain a stale controller
|
||||
// across sessions. Only the PUBLIC `childViewControllerForPointerLock` selector is touched
|
||||
// (App-Store-safe; no private API).
|
||||
|
||||
#if os(iOS)
|
||||
import ObjectiveC
|
||||
import UIKit
|
||||
|
||||
enum PointerLockChain {
|
||||
private static var forcedChildKey: UInt8 = 0
|
||||
/// Classes whose `childViewControllerForPointerLock` we've already IMP-swizzled (keyed by the
|
||||
/// class object). Main-thread only — pointer-lock resolution and capture toggles are all main.
|
||||
private static var swizzledClasses = Set<ObjectIdentifier>()
|
||||
/// Ancestors we've stamped with a forced child this engagement, held weakly so a deallocated
|
||||
/// SwiftUI controller drops out on its own (no dangling). disengage() clears every one — even
|
||||
/// if the live `parent` chain has since broken — so a stamped parent can never retain a stale
|
||||
/// controller subtree across sessions. One anchor is ever engaged at a time.
|
||||
private static let stampedParents = NSHashTable<UIViewController>.weakObjects()
|
||||
|
||||
private static func forcedChild(of vc: UIViewController) -> UIViewController? {
|
||||
objc_getAssociatedObject(vc, &forcedChildKey) as? UIViewController
|
||||
}
|
||||
|
||||
private static func setForcedChild(_ child: UIViewController?, on vc: UIViewController) {
|
||||
// RETAIN: while steering, the parent must keep the toward-anchor child alive. It's also
|
||||
// already a strong child of `vc` via UIKit containment, so this adds no cycle (the reverse
|
||||
// `.parent` link is weak), and disengage() always clears it — so it can't outlive a session.
|
||||
objc_setAssociatedObject(vc, &forcedChildKey, child, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
|
||||
/// Ensure `cls`'s `childViewControllerForPointerLock` getter consults the per-instance forced
|
||||
/// child first, falling back to the class's original implementation. Idempotent per class.
|
||||
private static func ensureSwizzled(_ cls: AnyClass) {
|
||||
let id = ObjectIdentifier(cls)
|
||||
guard !swizzledClasses.contains(id) else { return }
|
||||
swizzledClasses.insert(id)
|
||||
let selector = #selector(getter: UIViewController.childViewControllerForPointerLock)
|
||||
guard let method = class_getInstanceMethod(cls, selector) else { return }
|
||||
let originalIMP = method_getImplementation(method)
|
||||
typealias OriginalFn = @convention(c) (AnyObject, Selector) -> UIViewController?
|
||||
let original = unsafeBitCast(originalIMP, to: OriginalFn.self)
|
||||
let forwarding: @convention(block) (UIViewController) -> UIViewController? = { vc in
|
||||
if let forced = forcedChild(of: vc) { return forced }
|
||||
return original(vc, selector)
|
||||
}
|
||||
method_setImplementation(method, imp_implementationWithBlock(forwarding))
|
||||
}
|
||||
|
||||
/// Force every ancestor of `anchor` to forward pointer-lock resolution toward it, then ask the
|
||||
/// system to re-resolve. No-op when `anchor` isn't in a view-controller hierarchy yet (it
|
||||
/// re-runs from the anchor's appearance/parent callbacks once it is).
|
||||
static func engage(_ anchor: UIViewController) {
|
||||
disengage(anchor) // clear any prior engagement first (reparent / re-anchor)
|
||||
var child = anchor
|
||||
while let parent = child.parent {
|
||||
ensureSwizzled(object_getClass(parent)!)
|
||||
setForcedChild(child, on: parent)
|
||||
stampedParents.add(parent)
|
||||
child = parent
|
||||
}
|
||||
anchor.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
|
||||
/// Clear the forced forwarding on every stamped ancestor (so the SwiftUI parents stop retaining
|
||||
/// the anchor's subtree) and re-resolve to drop the lock.
|
||||
static func disengage(_ anchor: UIViewController) {
|
||||
for parent in stampedParents.allObjects {
|
||||
setForcedChild(nil, on: parent)
|
||||
}
|
||||
stampedParents.removeAllObjects()
|
||||
anchor.setNeedsUpdateOfPrefersPointerLocked()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user