feat(apple): gamepad ui
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// Controller-side haptic feedback for the gamepad menu UI (the host launcher + the library
|
||||
// coverflow). The couch case is the whole point: the user is holding a game controller, not the
|
||||
// iPhone/iPad, so a device-only `.sensoryFeedback` tick never reaches their hands — this plays a
|
||||
// short CoreHaptics transient on the ACTIVE controller instead, so a dpad move / launch / end-stop
|
||||
// is felt on the pad. (The views pair this with `.sensoryFeedback` so a touch/handheld user still
|
||||
// gets the device Taptic tick; the two are independent channels, and both firing is intended.)
|
||||
//
|
||||
// This is menu-only — it never runs during a stream (the session's own GamepadFeedback owns the
|
||||
// controller then), so there's no contention over the pad's haptic engine. Like GamepadMenuInput,
|
||||
// it reads `GamepadManager.shared.active` fresh and rebuilds its engine when the controller
|
||||
// changes, so a hot-swapped pad just starts buzzing on the next tick. Everything is best-effort:
|
||||
// a pad with no haptics (many Xbox pads on iOS, a Siri Remote) silently no-ops.
|
||||
|
||||
import CoreHaptics
|
||||
import Foundation
|
||||
import GameController
|
||||
|
||||
@MainActor
|
||||
public final class MenuHaptics {
|
||||
private let manager: GamepadManager
|
||||
/// The engine for the controller it was built against — dropped and rebuilt when `active`
|
||||
/// changes (identity compare) or after a stop/reset handler fires.
|
||||
private var engine: CHHapticEngine?
|
||||
private weak var boundController: GCController?
|
||||
|
||||
public init(manager: GamepadManager) {
|
||||
self.manager = manager
|
||||
}
|
||||
|
||||
/// A light, crisp detent — one per menu step. Deliberately tiny so a held direction repeating
|
||||
/// at ~5 Hz reads as a smooth ratchet rather than a jackhammer.
|
||||
public func move() {
|
||||
play(intensity: 0.45, sharpness: 0.75, duration: 0.02)
|
||||
}
|
||||
|
||||
/// A fuller, rounder pulse on confirm/launch — the "you did the thing" thunk.
|
||||
public func confirm() {
|
||||
play(intensity: 1.0, sharpness: 0.55, duration: 0.055)
|
||||
}
|
||||
|
||||
/// A soft, dull bump when a move is refused at the end of a non-wrapping list — low sharpness so
|
||||
/// it feels like hitting a wall, distinct from the crisp `move()` detent.
|
||||
public func boundary() {
|
||||
play(intensity: 0.7, sharpness: 0.18, duration: 0.06)
|
||||
}
|
||||
|
||||
/// Release the engine and forget the controller — call on the menu screen's disappear so the
|
||||
/// pad's haptic engine isn't held open while streaming or on the touch UI.
|
||||
public func stop() {
|
||||
engine?.stop(completionHandler: nil)
|
||||
engine = nil
|
||||
boundController = nil
|
||||
}
|
||||
|
||||
/// Fire a single transient. Rebuilds the engine against the current active controller if it
|
||||
/// changed; swallows every failure (a pad without a haptics engine, a transient XPC hiccup) —
|
||||
/// menu haptics are a nicety, never a correctness path.
|
||||
private func play(intensity: Float, sharpness: Float, duration: TimeInterval) {
|
||||
guard let controller = manager.active?.controller else {
|
||||
// No pad (or a non-forwardable one): nothing to buzz. Drop any stale engine.
|
||||
if boundController != nil { stop() }
|
||||
return
|
||||
}
|
||||
guard let engine = engine(for: controller) else { return }
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticTransient,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: duration)
|
||||
do {
|
||||
let player = try engine.makePlayer(with: CHHapticPattern(events: [event], parameters: []))
|
||||
try player.start(atTime: CHHapticTimeImmediate)
|
||||
} catch {
|
||||
// The engine went stale between builds (stopped/reset). Drop it; the next tick rebuilds.
|
||||
self.engine = nil
|
||||
boundController = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The started engine for `controller`, (re)built on first use or after a controller swap.
|
||||
private func engine(for controller: GCController) -> CHHapticEngine? {
|
||||
if let engine, boundController === controller { return engine }
|
||||
engine?.stop(completionHandler: nil)
|
||||
engine = nil
|
||||
boundController = nil
|
||||
guard let built = controller.haptics?.createEngine(withLocality: .default) else { return nil }
|
||||
// Menu ticks carry no audio — keep the engine out of the app's audio session (the same
|
||||
// discipline the session RumbleRenderer uses).
|
||||
built.playsHapticsOnly = true
|
||||
// The haptic server can pull the engine out from under us (backgrounding, an audio
|
||||
// interruption, a controller drop); drop our reference so the next tick lazily rebuilds
|
||||
// rather than throwing forever.
|
||||
built.stoppedHandler = { [weak self] _ in
|
||||
Task { @MainActor in self?.dropEngine(if: controller) }
|
||||
}
|
||||
built.resetHandler = { [weak self] in
|
||||
Task { @MainActor in self?.dropEngine(if: controller) }
|
||||
}
|
||||
do {
|
||||
try built.start()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
engine = built
|
||||
boundController = controller
|
||||
return built
|
||||
}
|
||||
|
||||
/// Drop the cached engine only if it's still the one for `controller` — a handler firing after a
|
||||
/// swap must not clobber the freshly built engine for the new pad.
|
||||
private func dropEngine(if controller: GCController) {
|
||||
guard boundController === controller else { return }
|
||||
engine = nil
|
||||
boundController = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user