133e25849d
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>
74 lines
3.2 KiB
Swift
74 lines
3.2 KiB
Swift
#if DEBUG
|
|
import Combine
|
|
import GameController
|
|
|
|
/// Local feedback driver for the Settings → Controllers "Test Controller" panel (DEBUG builds
|
|
/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a
|
|
/// live session uses — just aimed at the physically-connected controller instead of the
|
|
/// host→client feedback planes — so rumble, the adaptive triggers, the lightbar and the player
|
|
/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point:
|
|
/// a passing test exercises the exact code a session runs.
|
|
@MainActor
|
|
public final class ControllerTester: ObservableObject {
|
|
private let renderer = RumbleRenderer()
|
|
private weak var controller: GCController?
|
|
|
|
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
|
/// for the test panel to display so it's obvious which path a given pad takes.
|
|
@Published public private(set) var rumbleBackend = "—"
|
|
|
|
public init() {}
|
|
|
|
/// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every
|
|
/// active-controller change.
|
|
public func target(_ c: GCController?) {
|
|
guard c !== controller else { return }
|
|
controller = c
|
|
renderer.retarget(c) { [weak self] note in
|
|
Task { @MainActor in self?.rumbleBackend = note }
|
|
}
|
|
}
|
|
|
|
/// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to
|
|
/// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`.
|
|
public func rumble(low: Float, high: Float) {
|
|
func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) }
|
|
renderer.apply(low: u16(low), high: u16(high))
|
|
}
|
|
|
|
public func stopRumble() { renderer.apply(low: 0, high: 0) }
|
|
|
|
/// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect`
|
|
/// renderer. `right == false` → L2, `true` → R2. No-op on a non-DualSense pad.
|
|
public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) {
|
|
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
|
effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger)
|
|
}
|
|
|
|
public func resetTriggers() {
|
|
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
|
ds.leftTrigger.setModeOff()
|
|
ds.rightTrigger.setModeOff()
|
|
}
|
|
|
|
/// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light.
|
|
public func setLight(_ color: GCColor?) {
|
|
controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0)
|
|
}
|
|
|
|
/// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear).
|
|
public func setPlayerIndex(_ index: GCControllerPlayerIndex) {
|
|
controller?.playerIndex = index
|
|
}
|
|
|
|
/// Silence every channel and release the controller — call on the panel's disappear.
|
|
public func stop() {
|
|
resetTriggers()
|
|
setPlayerIndex(.indexUnset)
|
|
setLight(nil)
|
|
renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref
|
|
controller = nil
|
|
}
|
|
}
|
|
#endif
|