4e00037a89
apple / swift (push) Successful in 1m4s
android / android (push) Successful in 4m33s
ci / rust (push) Successful in 5m4s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m12s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m48s
apple / screenshots (push) Successful in 5m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m24s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m46s
Stream reliability - Default to the stage-2 presenter (VTDecompressionSession + CAMetalLayer): it detects and recovers a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no app-side recovery (confirmed Apple limitation). Stage 1 is now a DEBUG-only presenter toggle, plus the automatic no-Metal fallback. - Stage-2 pixel-perfect: render the drawable at the decoded size (shader stays 1:1 = identity) and let the layer's contentsGravity scale via the system compositor — the same path stage-1's videoGravity used — instead of scaling in-shader. - Loss recovery in both pumps is now a persistent awaitingIDR want, retried until an IDR actually lands, so a keyframe request swallowed by the throttle can't strand a frozen frame; 100 ms keyframe throttle to match the Android path. - Fix "Publishing changes from within view updates": defer the HostStore writes out of the .onChange(of: model.phase) callback. - Move AVAudioSession setActive/setCategory off the main thread (async on a shared serial queue) to stop the UI-stall warning. Controllers - Rumble: capped-exponential backoff when the gamecontrollerd.haptics XPC breaks (-4811) so a transient server interruption self-heals instead of cascading; playsHapticsOnly so a controller engine doesn't join the always-active streaming audio session. - Host cards: iPad pointer "magnet" hover effect; iPhone press scale + light haptic. UI / design - Ship Geist (SIL OFL 1.1) as the app font (bundled OTFs + registration), with the license surfaced in Acknowledgements. - Restructure iOS/iPadOS Settings into a category NavigationSplitView; resolution wheel with custom-resolution entry; 10-bit HDR toggle in Display. - Industrial host-card redesign (left-aligned, bold, brand monogram tiles). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
388 lines
16 KiB
Swift
388 lines
16 KiB
Swift
// DEBUG-only controller test panel, reached from Settings → Controllers → "Test Controller…".
|
|
// It shows the live input of the active controller and lets you fire the host→client feedback
|
|
// channels — rumble, DualSense adaptive triggers, lightbar, player LEDs — straight at the
|
|
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
|
|
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
|
|
//
|
|
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
|
|
// layout); macOS + iOS/iPadOS cover the validation need.
|
|
|
|
#if DEBUG && !os(tvOS)
|
|
import GameController
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
struct ControllerTestView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@ObservedObject private var gamepads = GamepadManager.shared
|
|
@StateObject private var tester = ControllerTester()
|
|
|
|
@State private var heavyOn = false
|
|
@State private var lightOn = false
|
|
@State private var intensity = 0.75
|
|
@State private var triggerTarget = TriggerTarget.both
|
|
@State private var playerLED = -1
|
|
|
|
private enum TriggerTarget: String, CaseIterable, Identifiable {
|
|
case left = "L2", right = "R2", both = "Both"
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
private struct TriggerDemo: Identifiable {
|
|
let label: String
|
|
let effect: DualSenseTriggerEffect
|
|
var id: String { label }
|
|
}
|
|
|
|
private static let triggerDemos: [TriggerDemo] = [
|
|
.init(label: "Off", effect: .off),
|
|
.init(label: "Resistance", effect: .feedback(start: 0.3, strength: 0.7)),
|
|
.init(label: "Weapon", effect: .weapon(start: 0.4, end: 0.7, strength: 0.9)),
|
|
.init(label: "Vibration", effect: .vibration(start: 0.1, amplitude: 0.8, frequency: 0.5)),
|
|
.init(label: "Bow", effect: .slope(start: 0.2, end: 0.9, startStrength: 0.2, endStrength: 0.9)),
|
|
]
|
|
|
|
// (display name, hardware colour, swatch colour)
|
|
private static let lightSwatches: [(String, GCColor, Color)] = [
|
|
("Red", GCColor(red: 1, green: 0, blue: 0), .red),
|
|
("Green", GCColor(red: 0, green: 1, blue: 0), .green),
|
|
("Blue", GCColor(red: 0, green: 0.2, blue: 1), .blue),
|
|
("White", GCColor(red: 1, green: 1, blue: 1), .white),
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text("Test Controller").font(.geist(17, .semibold, relativeTo: .headline))
|
|
Spacer()
|
|
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
|
}
|
|
.padding()
|
|
Divider()
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if let active = gamepads.active {
|
|
header(active)
|
|
inputCard
|
|
rumbleCard()
|
|
triggerCard(active)
|
|
extrasCard(active)
|
|
} else {
|
|
ContentUnavailableView(
|
|
"No controller",
|
|
systemImage: "gamecontroller",
|
|
description: Text("Connect a controller and pick it under "
|
|
+ "Settings → Controllers → Use controller."))
|
|
.frame(maxWidth: .infinity, minHeight: 220)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
.frame(minWidth: 420, minHeight: 540)
|
|
.onAppear { tester.target(gamepads.active?.controller) }
|
|
.onDisappear { tester.stop() }
|
|
.onChange(of: gamepads.active?.id) { _, _ in
|
|
heavyOn = false
|
|
lightOn = false
|
|
playerLED = -1
|
|
tester.target(gamepads.active?.controller)
|
|
}
|
|
}
|
|
|
|
// MARK: Header
|
|
|
|
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.secondary)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
|
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
// MARK: Input
|
|
|
|
private var inputCard: some View {
|
|
card("Input") {
|
|
// Poll the live controller at 30 Hz — no handlers installed, so nothing else's
|
|
// capture is disturbed.
|
|
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
|
|
if let gp = gamepads.active?.controller.extendedGamepad {
|
|
inputReadout(gp, controller: gamepads.active?.controller)
|
|
} else {
|
|
Text("Not an extended gamepad").foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack(alignment: .top, spacing: 20) {
|
|
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
|
pressed: g.leftThumbstickButton?.isPressed ?? false)
|
|
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
|
pressed: g.rightThumbstickButton?.isPressed ?? false)
|
|
VStack(spacing: 8) {
|
|
triggerBar("L2", value: g.leftTrigger.value)
|
|
triggerBar("R2", value: g.rightTrigger.value)
|
|
}
|
|
}
|
|
buttonGrid(g)
|
|
if let tp = Self.touchpad(g) {
|
|
touchpadView(tp)
|
|
}
|
|
if let m = controller?.motion {
|
|
motionReadout(m)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
|
|
VStack(spacing: 4) {
|
|
ZStack {
|
|
Circle().stroke(Color.secondary.opacity(0.3))
|
|
Circle()
|
|
.fill(pressed ? Color.accentColor : Color.secondary)
|
|
.frame(width: 12, height: 12)
|
|
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
|
|
}
|
|
.frame(width: 56, height: 56)
|
|
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func triggerBar(_ label: String, value: Float) -> some View {
|
|
HStack(spacing: 6) {
|
|
Text(label).font(.caption2.monospaced()).frame(width: 22, alignment: .leading)
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(Color.secondary.opacity(0.15))
|
|
Capsule().fill(Color.accentColor).frame(width: geo.size.width * CGFloat(value))
|
|
}
|
|
}
|
|
.frame(height: 10)
|
|
Text(mag(value)).font(.caption2.monospaced()).frame(width: 34, alignment: .trailing)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(width: 150)
|
|
}
|
|
|
|
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
|
|
var items: [(String, Bool)] = [
|
|
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
|
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
|
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
|
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
|
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
|
("Menu", g.buttonMenu.isPressed),
|
|
("Opts", g.buttonOptions?.isPressed ?? false),
|
|
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
|
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
|
]
|
|
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
|
|
return LazyVGrid(
|
|
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
|
|
) {
|
|
ForEach(items.indices, id: \.self) { i in
|
|
Text(items[i].0)
|
|
.font(.caption.monospaced())
|
|
.frame(maxWidth: .infinity, minHeight: 24)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(items[i].1 ? Color.accentColor : Color.secondary.opacity(0.15)))
|
|
.foregroundStyle(items[i].1 ? Color.white : Color.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func touchpadView(
|
|
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
|
button: GCControllerButtonInput)
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
|
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
|
fingerDot(tp.primary, color: .accentColor)
|
|
fingerDot(tp.secondary, color: .orange)
|
|
}
|
|
.frame(width: 150, height: 74)
|
|
}
|
|
}
|
|
|
|
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
|
|
let x = pad.xAxis.value, y = pad.yAxis.value
|
|
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
|
|
return Circle().fill(color).frame(width: 10, height: 10)
|
|
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
|
|
.opacity(active ? 1 : 0)
|
|
}
|
|
|
|
private func motionReadout(_ m: GCMotion) -> some View {
|
|
let a = Self.totalAccel(m)
|
|
return VStack(alignment: .leading, spacing: 2) {
|
|
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
|
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
|
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
|
.font(.caption2.monospaced())
|
|
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
|
|
.font(.caption2.monospaced())
|
|
}
|
|
}
|
|
|
|
// MARK: Rumble
|
|
|
|
private func rumbleCard() -> some View {
|
|
card("Rumble") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Picker("Strength", selection: $intensity) {
|
|
Text("25%").tag(0.25)
|
|
Text("50%").tag(0.5)
|
|
Text("75%").tag(0.75)
|
|
Text("100%").tag(1.0)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
|
Toggle("Light motor (right)", isOn: $lightOn)
|
|
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
|
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
|
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
|
+ "can't reach its motors on macOS).")
|
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
|
}
|
|
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
|
.onChange(of: lightOn) { _, _ in applyRumble() }
|
|
.onChange(of: intensity) { _, _ in applyRumble() }
|
|
}
|
|
}
|
|
|
|
private func applyRumble() {
|
|
tester.rumble(low: heavyOn ? Float(intensity) : 0, high: lightOn ? Float(intensity) : 0)
|
|
}
|
|
|
|
// MARK: Adaptive triggers
|
|
|
|
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
|
card("Adaptive triggers") {
|
|
if c.hasAdaptiveTriggers {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Picker("Apply to", selection: $triggerTarget) {
|
|
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
|
|
}
|
|
.pickerStyle(.segmented)
|
|
LazyVGrid(
|
|
columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8
|
|
) {
|
|
ForEach(Self.triggerDemos) { demo in
|
|
Button(demo.label) { applyTrigger(demo.effect) }
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
|
}
|
|
} else {
|
|
Text("Adaptive triggers need a DualSense.")
|
|
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyTrigger(_ e: DualSenseTriggerEffect) {
|
|
switch triggerTarget {
|
|
case .left: tester.applyTrigger(e, right: false)
|
|
case .right: tester.applyTrigger(e, right: true)
|
|
case .both:
|
|
tester.applyTrigger(e, right: false)
|
|
tester.applyTrigger(e, right: true)
|
|
}
|
|
}
|
|
|
|
// MARK: Lightbar + player LED
|
|
|
|
@ViewBuilder
|
|
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
|
if c.hasLight {
|
|
card("Lightbar & player LED") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 12) {
|
|
ForEach(Self.lightSwatches.indices, id: \.self) { i in
|
|
Button { tester.setLight(Self.lightSwatches[i].1) } label: {
|
|
Circle().fill(Self.lightSwatches[i].2)
|
|
.frame(width: 26, height: 26)
|
|
.overlay(Circle().stroke(Color.secondary.opacity(0.4)))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
Button("Off") { tester.setLight(nil) }.buttonStyle(.bordered)
|
|
}
|
|
Picker("Player LED", selection: $playerLED) {
|
|
Text("Off").tag(-1)
|
|
Text("1").tag(0)
|
|
Text("2").tag(1)
|
|
Text("3").tag(2)
|
|
Text("4").tag(3)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.onChange(of: playerLED) { _, v in
|
|
tester.setPlayerIndex(GCControllerPlayerIndex(rawValue: v) ?? .indexUnset)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Helpers
|
|
|
|
private func card<Content: View>(
|
|
_ title: String, @ViewBuilder _ content: () -> Content
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(title).font(.geist(15, .semibold, relativeTo: .subheadline))
|
|
content()
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(14)
|
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08)))
|
|
}
|
|
|
|
private func sgn(_ v: Float) -> String { String(format: "%+.2f", v) }
|
|
private func mag(_ v: Float) -> String { String(format: "%.2f", v) }
|
|
|
|
/// The touchpad surface of a PlayStation pad — `GCDualSenseGamepad` and `GCDualShockGamepad`
|
|
/// don't share a touchpad type, so downcast either. `nil` for any other controller.
|
|
private static func touchpad(
|
|
_ g: GCExtendedGamepad
|
|
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
|
button: GCControllerButtonInput)? {
|
|
if let ds = g as? GCDualSenseGamepad {
|
|
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
|
|
}
|
|
if let ds4 = g as? GCDualShockGamepad {
|
|
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Total acceleration in g: gravity + user when the pad splits them, else the raw vector.
|
|
private static func totalAccel(_ m: GCMotion) -> (Double, Double, Double) {
|
|
if m.hasGravityAndUserAcceleration {
|
|
return (m.gravity.x + m.userAcceleration.x,
|
|
m.gravity.y + m.userAcceleration.y,
|
|
m.gravity.z + m.userAcceleration.z)
|
|
}
|
|
return (m.acceleration.x, m.acceleration.y, m.acceleration.z)
|
|
}
|
|
}
|
|
#endif
|