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,87 @@
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Open-source acknowledgements: punktfunk's own license (MIT OR Apache-2.0) followed by the
|
||||
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
|
||||
struct AcknowledgementsView: View {
|
||||
private var version: String? {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
|
||||
// ~885 KB total) load lazily as they scroll into view — a single Text that large overshoots
|
||||
// the text-rendering height limit (blank below the limit + very slow). spacing 0 keeps the
|
||||
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("punktfunk")
|
||||
.font(.geist(22, .bold, relativeTo: .title2))
|
||||
if let version {
|
||||
Text("Version \(version)")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(Licenses.appLicense)
|
||||
.font(.caption.monospaced())
|
||||
.modifier(SelectableText())
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Bundled font")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text("punktfunk ships the Geist typeface (Geist Sans), "
|
||||
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
||||
+ "License 1.1.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
if !Licenses.fontLicense.isEmpty {
|
||||
Text(Licenses.fontLicense)
|
||||
.font(.caption2.monospaced())
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(
|
||||
"punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
ForEach(Licenses.thirdPartyNoticesChunks.indices, id: \.self) { i in
|
||||
Text(Licenses.thirdPartyNoticesChunks[i])
|
||||
.font(.caption2.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 900, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
#if os(tvOS)
|
||||
.padding(40)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("Acknowledgements")
|
||||
}
|
||||
}
|
||||
|
||||
/// `textSelection(.enabled)` is unavailable on tvOS, so apply it only where it exists.
|
||||
private struct SelectableText: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
#if os(tvOS)
|
||||
content
|
||||
#else
|
||||
content.textSelection(.enabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// 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
|
||||
@@ -0,0 +1,357 @@
|
||||
// The gamepad-driven settings screen (iOS/iPadOS/macOS): the couch-relevant subset of SettingsView,
|
||||
// restyled as a console settings page and fully navigable with a controller — up/down moves the
|
||||
// focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the
|
||||
// gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom
|
||||
// resolutions, the log bitrate slider, debug tools), and both write the same DefaultsKey storage,
|
||||
// so values round-trip freely between the two.
|
||||
//
|
||||
// Rows are rebuilt from live @AppStorage on every render; the focus list dispatches adjust/
|
||||
// activate back here BY ROW ID (see `adjust`/`activate`), so a stored input callback can never act
|
||||
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
|
||||
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||||
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
import GameController
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.enable444) private var enable444 = true
|
||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
items: rows,
|
||||
focusID: $focusID,
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { dismiss() }
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: 620)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Text("Settings")
|
||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, compact ? 4 : 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(focusedDetail)
|
||||
.font(.geist(13, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
])
|
||||
}
|
||||
.padding(.leading, 22)
|
||||
.padding(.trailing, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .bottom) }
|
||||
}
|
||||
.background { GamepadScreenBackground() }
|
||||
.onAppear {
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
|
||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||
/// rides the cancel action.
|
||||
private var closeButton: some View {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 34, height: 34)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityLabel("Close settings")
|
||||
}
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let header = row.header {
|
||||
Text(header)
|
||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, 14)
|
||||
}
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: row.icon)
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
|
||||
.frame(width: 28)
|
||||
Text(row.label)
|
||||
.font(.geist(16, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 12)
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
Text(row.value)
|
||||
.font(.geist(15, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(focused ? .white : .white.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 13)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.white.opacity(focused ? 0.1 : 0))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(.white.opacity(focused ? 0.22 : 0), lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
}
|
||||
}
|
||||
|
||||
private var focusedDetail: String {
|
||||
rows.first { $0.id == focusID }?.detail ?? " "
|
||||
}
|
||||
|
||||
// MARK: - Row model
|
||||
|
||||
private struct Row: Identifiable {
|
||||
let id: String
|
||||
/// Section header drawn above this row (the first row of each group carries it).
|
||||
var header: String?
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
/// One-line explanation shown near the hint bar while this row is focused.
|
||||
let detail: String
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
let activate: () -> Void
|
||||
}
|
||||
|
||||
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
||||
/// (never on state captured at wire time).
|
||||
private func adjust(id: String, by delta: Int) -> Bool {
|
||||
rows.first { $0.id == id }?.adjust(delta) ?? false
|
||||
}
|
||||
|
||||
private func activate(id: String) {
|
||||
rows.first { $0.id == id }?.activate()
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
let resolution = resolutionOptions
|
||||
let refresh = SettingsOptions.refreshRates(including: hz)
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||||
return [
|
||||
choiceRow(
|
||||
id: "resolution", header: "Stream", icon: "aspectratio",
|
||||
label: "Resolution",
|
||||
detail: "The host creates a virtual display at exactly this size — no scaling.",
|
||||
options: resolution, current: "\(width)x\(height)"
|
||||
) { tag in
|
||||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||||
guard parts.count == 2 else { return }
|
||||
width = parts[0]
|
||||
height = parts[1]
|
||||
},
|
||||
choiceRow(
|
||||
id: "refresh", icon: "gauge.with.needle", label: "Refresh rate",
|
||||
detail: "Rates this display can actually show.",
|
||||
options: refresh, current: hz
|
||||
) { hz = $0 },
|
||||
choiceRow(
|
||||
id: "bitrate", icon: "speedometer", label: "Bitrate",
|
||||
detail: "Automatic uses the host's default (20 Mbps). "
|
||||
+ "Run a speed test from the touch UI for an informed value.",
|
||||
options: bitrate, current: bitrateKbps
|
||||
) { bitrateKbps = $0 },
|
||||
choiceRow(
|
||||
id: "compositor", icon: "macwindow", label: "Compositor",
|
||||
detail: "Which compositor drives the virtual output — honored only if "
|
||||
+ "available on the host.",
|
||||
options: SettingsOptions.compositors, current: compositor
|
||||
) { compositor = $0 },
|
||||
|
||||
choiceRow(
|
||||
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
||||
detail: "A preference — the host falls back if it can't encode this one "
|
||||
+ "(10-bit and 4:4:4 are HEVC-only).",
|
||||
options: SettingsOptions.codecs, current: codec
|
||||
) { codec = $0 },
|
||||
toggleRow(
|
||||
id: "hdr", icon: "sun.max", label: "10-bit HDR",
|
||||
detail: "HDR10 — engages when the host sends HDR content and this display "
|
||||
+ "supports it.",
|
||||
value: $hdrEnabled),
|
||||
toggleRow(
|
||||
id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)",
|
||||
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
||||
+ "hardware decode.",
|
||||
value: $enable444),
|
||||
|
||||
choiceRow(
|
||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||
detail: "The speaker layout requested from the host.",
|
||||
options: SettingsOptions.audioChannels, current: audioChannels
|
||||
) { audioChannels = $0 },
|
||||
toggleRow(
|
||||
id: "mic", icon: "mic", label: "Microphone",
|
||||
detail: "Send this device's microphone to the host's virtual mic.",
|
||||
value: $micEnabled),
|
||||
|
||||
choiceRow(
|
||||
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
|
||||
detail: "Which pad is forwarded to the host, as player 1.",
|
||||
options: controllers, current: gamepads.preferredID
|
||||
) { gamepads.preferredID = $0 },
|
||||
choiceRow(
|
||||
id: "padType", icon: "dpad", label: "Controller type",
|
||||
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||||
options: SettingsOptions.padTypes, current: gamepadType
|
||||
) { gamepadType = $0 },
|
||||
|
||||
toggleRow(
|
||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||
detail: "Resolution, frame rate, throughput and latency while streaming.",
|
||||
value: $hudEnabled),
|
||||
choiceRow(
|
||||
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
||||
detail: "Which corner the statistics overlay sits in.",
|
||||
options: SettingsOptions.hudPlacements, current: hudPlacement
|
||||
) { hudPlacement = $0 },
|
||||
toggleRow(
|
||||
id: "library", icon: "square.grid.2x2", label: "Game library",
|
||||
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) "
|
||||
+ "(experimental).",
|
||||
value: $libraryEnabled),
|
||||
toggleRow(
|
||||
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
}
|
||||
|
||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||
/// (set via the touch settings), so cycling starts from it instead of jumping.
|
||||
private var resolutionOptions: [(label: String, tag: String)] {
|
||||
var options = SettingsOptions.resolutionModes()
|
||||
.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") }
|
||||
let current = "\(width)x\(height)"
|
||||
if !options.contains(where: { $0.tag == current }) {
|
||||
options.insert((label: "Custom · \(width) × \(height)", tag: current), at: 0)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/// The active controller's user-facing name for a button (for detail strings).
|
||||
private func buttonName(
|
||||
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, _ fallback: String
|
||||
) -> String {
|
||||
gamepads.active?.controller.extendedGamepad?[keyPath: button].localizedName ?? fallback
|
||||
}
|
||||
|
||||
// MARK: - Row builders
|
||||
|
||||
private func choiceRow<T: Equatable>(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
|
||||
) -> Row {
|
||||
let index = options.firstIndex { $0.tag == current }
|
||||
return Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
value: index.map { options[$0].label } ?? "—",
|
||||
detail: detail,
|
||||
adjust: { delta in
|
||||
// Unknown current value: snap to the first option on any step.
|
||||
guard let index else {
|
||||
guard let first = options.first else { return false }
|
||||
write(first.tag)
|
||||
return true
|
||||
}
|
||||
let target = index + delta
|
||||
guard target >= 0, target < options.count else { return false }
|
||||
write(options[target].tag)
|
||||
return true
|
||||
},
|
||||
activate: {
|
||||
guard let index else { return write(options.first?.tag ?? current) }
|
||||
write(options[(index + 1) % options.count].tag)
|
||||
})
|
||||
}
|
||||
|
||||
private func toggleRow(
|
||||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||||
value: Binding<Bool>
|
||||
) -> Row {
|
||||
Row(
|
||||
id: id, header: header, icon: icon, label: label,
|
||||
value: value.wrappedValue ? "On" : "Off",
|
||||
detail: detail,
|
||||
adjust: { delta in
|
||||
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
|
||||
let target = delta > 0
|
||||
guard value.wrappedValue != target else { return false }
|
||||
value.wrappedValue = target
|
||||
return true
|
||||
},
|
||||
activate: { value.wrappedValue.toggle() })
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,60 @@
|
||||
// SettingsView's navigation and presentation helpers: the iOS settings categories, the iPad
|
||||
// sheet sizing, and the bounded-slider clamp.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
/// The settings groups, mirroring the macOS preference tabs. On iPad each is a sidebar row that
|
||||
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
||||
/// private) so the screenshot harness can open SettingsView on a specific category.
|
||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case general, display, audio, controllers, advanced, about
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: return "General"
|
||||
case .display: return "Display"
|
||||
case .audio: return "Audio"
|
||||
case .controllers: return "Controllers"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .general: return "gearshape"
|
||||
case .display: return "display"
|
||||
case .audio: return "speaker.wave.2"
|
||||
case .controllers: return "gamecontroller"
|
||||
case .advanced: return "slider.horizontal.3"
|
||||
case .about: return "info.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Present the settings sheet large on iPad so the NavigationSplitView has room for its
|
||||
/// sidebar + detail — a default form sheet is too narrow and the split view would collapse to
|
||||
/// the iPhone push list. No-op on iPhone (the standard sheet is already right) and on iOS 17
|
||||
/// (no `presentationSizing` — it falls back to the default sheet, which still degrades cleanly
|
||||
/// to the push list).
|
||||
@ViewBuilder
|
||||
func settingsSheetSizing() -> some View {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad, #available(iOS 18, *) {
|
||||
presentationSizing(.page)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
extension Double {
|
||||
/// The log-scale slider mapping needs a bounded input (Automatic stores 0).
|
||||
func clamped(_ lo: Double, _ hi: Double) -> Double {
|
||||
Swift.min(Swift.max(self, lo), hi)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// The option lists every settings surface renders from — one source of truth shared by the
|
||||
// touch/desktop SettingsView (Pickers), the tvOS pushed selection rows, and the gamepad settings
|
||||
// screen (GamepadSettingsView's left/right cycling). Pure data + small pure helpers; anything that
|
||||
// reads live view state (e.g. the bitrate slider mapping) stays on SettingsView.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsOptions {
|
||||
/// Compositor choices — the `tag` is the wire value (`PunktfunkConnection.Compositor` raw).
|
||||
static let compositors: [(label: String, tag: Int)] = [
|
||||
("Automatic", 0),
|
||||
("KWin (KDE Plasma)", 1),
|
||||
("wlroots (Sway / Hyprland)", 2),
|
||||
("Mutter (GNOME)", 3),
|
||||
("gamescope", 4),
|
||||
]
|
||||
|
||||
static let audioChannels: [(label: String, tag: Int)] = [
|
||||
("Stereo", 2),
|
||||
("5.1 Surround", 6),
|
||||
("7.1 Surround", 8),
|
||||
]
|
||||
|
||||
/// Virtual-pad types — the `tag` is the wire value (`PunktfunkConnection.GamepadType` raw).
|
||||
static let padTypes: [(label: String, tag: Int)] = [
|
||||
("Automatic", 0),
|
||||
("Xbox 360", 1),
|
||||
("Xbox One", 3),
|
||||
("DualSense", 2),
|
||||
("DualShock 4", 4),
|
||||
]
|
||||
|
||||
static let hudPlacements: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
|
||||
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
|
||||
/// No AV1: this client's VideoToolbox path decodes H.264/HEVC only (hosts don't emit AV1 on
|
||||
/// the native path yet).
|
||||
static let codecs: [(label: String, tag: String)] = [
|
||||
("Automatic", "auto"),
|
||||
("HEVC (H.265)", "hevc"),
|
||||
("H.264 (AVC)", "h264"),
|
||||
]
|
||||
|
||||
// MARK: - Bitrate
|
||||
|
||||
/// Discrete bitrate steps for the surfaces with no Slider (tvOS pushed pickers, the gamepad
|
||||
/// settings' left/right cycling), up to the same 3 Gbps ceiling the slider has.
|
||||
static let bitratePresets: [(label: String, tag: Int)] = [
|
||||
("Automatic", 0),
|
||||
("10 Mbps", 10_000),
|
||||
("20 Mbps", 20_000),
|
||||
("40 Mbps", 40_000),
|
||||
("80 Mbps", 80_000),
|
||||
("150 Mbps", 150_000),
|
||||
("300 Mbps", 300_000),
|
||||
("500 Mbps", 500_000),
|
||||
("1 Gbps", 1_000_000),
|
||||
("1.5 Gbps", 1_500_000),
|
||||
("2 Gbps", 2_000_000),
|
||||
("3 Gbps", 3_000_000),
|
||||
]
|
||||
|
||||
/// The presets plus the currently stored value when it isn't one of them (set via the touch
|
||||
/// slider or a synced device) — so the current choice stays visible/selectable.
|
||||
static func bitrateOptions(current: Int) -> [(label: String, tag: Int)] {
|
||||
var options = bitratePresets
|
||||
if !options.contains(where: { $0.tag == current }) {
|
||||
options.insert(
|
||||
(SpeedTestSheet.mbpsLabel(kbps: current) + " (custom)", current), at: 1)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// MARK: - Controllers
|
||||
|
||||
/// "Use controller" choices: Automatic, every forwardable controller, and — so a stale pin
|
||||
/// stays visible instead of leaving the selection tag-less — any pinned id that is NOT among
|
||||
/// the selectable (extended) entries, present-but-unusable included.
|
||||
@MainActor
|
||||
static func controllerOptions(_ gamepads: GamepadManager) -> [(label: String, tag: String)] {
|
||||
let selectable = gamepads.controllers.filter(\.isExtended)
|
||||
var options: [(label: String, tag: String)] = [("Automatic", "")]
|
||||
options += selectable.map { ($0.name, $0.id) }
|
||||
if !gamepads.preferredID.isEmpty,
|
||||
!selectable.contains(where: { $0.id == gamepads.preferredID }) {
|
||||
options.append(("Unavailable controller", gamepads.preferredID))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
// MARK: - Stream mode (iOS + macOS pickers; tvOS builds its own preset list)
|
||||
|
||||
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
|
||||
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
||||
("720p", 1280, 720),
|
||||
("1080p", 1920, 1080),
|
||||
("1440p", 2560, 1440),
|
||||
("4K", 3840, 2160),
|
||||
("Ultrawide 1080p", 2560, 1080),
|
||||
("Ultrawide 1440p", 3440, 1440),
|
||||
("Super ultrawide", 5120, 1440),
|
||||
]
|
||||
|
||||
/// This device's native mode first, then the presets, deduped by dimensions (native wins a
|
||||
/// tie).
|
||||
@MainActor
|
||||
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
|
||||
var native: [(name: String, w: Int, h: Int)] = []
|
||||
#if os(iOS)
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
||||
native = [("This device",
|
||||
Int(max(bounds.width, bounds.height)),
|
||||
Int(min(bounds.width, bounds.height)))]
|
||||
#else
|
||||
if let screen = NSScreen.main {
|
||||
let scale = screen.backingScaleFactor
|
||||
native = [("This display",
|
||||
Int(screen.frame.width * scale),
|
||||
Int(screen.frame.height * scale))]
|
||||
}
|
||||
#endif
|
||||
var seen = Set<String>()
|
||||
return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted }
|
||||
}
|
||||
|
||||
/// Refresh rates the device can actually display (no point asking the host to render frames
|
||||
/// the screen can't show), plus any stored custom value so it stays selectable.
|
||||
@MainActor
|
||||
static func refreshRates(including current: Int) -> [Int] {
|
||||
#if os(iOS)
|
||||
let maxHz = UIScreen.main.maximumFramesPerSecond
|
||||
#else
|
||||
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
|
||||
#endif
|
||||
var rates = [60, 120, 240].filter { $0 <= maxHz }
|
||||
if rates.isEmpty { rates = [maxHz] }
|
||||
if !rates.contains(current) { rates.append(current) }
|
||||
return rates.sorted()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
// SettingsView's shared sections — each setting's Section is defined exactly once here and
|
||||
// composed by the per-platform bodies in SettingsView.swift.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
extension SettingsView {
|
||||
// MARK: - Sections (shared)
|
||||
|
||||
@ViewBuilder var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS)
|
||||
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and
|
||||
// a segmented refresh-rate control — the same family as the Clock/Timer pickers. The host
|
||||
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The
|
||||
// last wheel row, "Custom…", reveals width/height/refresh fields for an arbitrary mode.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
#elseif os(macOS)
|
||||
HStack {
|
||||
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
||||
Text("×")
|
||||
TextField("", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
}
|
||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||
LabeledContent("") {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
}
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .trailing)
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text("Stream mode")
|
||||
} footer: {
|
||||
Text("The host creates a virtual output at exactly this mode — "
|
||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: - Stream mode (iOS wheel)
|
||||
|
||||
/// Sentinel wheel tag for the "Custom…" row. Real tags are "WxH" (digits + "x"), so this can't
|
||||
/// collide with a resolution.
|
||||
private static let customResolutionTag = "custom"
|
||||
|
||||
/// Wheel rows: the resolution modes (device native first — see `SettingsOptions`), then a
|
||||
/// "Custom…" row that reveals the numeric fields.
|
||||
private var resolutionChoices: [(label: String, tag: String)] {
|
||||
SettingsOptions.resolutionModes()
|
||||
.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") }
|
||||
+ [(label: "Custom…", tag: Self.customResolutionTag)]
|
||||
}
|
||||
|
||||
private var presetResolutionTags: Set<String> {
|
||||
Set(SettingsOptions.resolutionModes().map { "\($0.w)x\($0.h)" })
|
||||
}
|
||||
|
||||
/// True when the editable custom fields should show: the wheel is parked on "Custom…" (sticky),
|
||||
/// or the stored size simply isn't one of the presets (e.g. a value synced from a Mac) — so a
|
||||
/// non-preset mode stays editable across relaunches without a persisted flag.
|
||||
private var isCustomResolution: Bool {
|
||||
customMode || !presetResolutionTags.contains("\(width)x\(height)")
|
||||
}
|
||||
|
||||
/// The wheel works in "WxH" tags so one selection drives both width and height; the custom
|
||||
/// sentinel toggles `customMode` instead of writing a size.
|
||||
private var resolutionSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { isCustomResolution ? Self.customResolutionTag : "\(width)x\(height)" },
|
||||
set: { tag in
|
||||
if tag == Self.customResolutionTag {
|
||||
customMode = true
|
||||
return
|
||||
}
|
||||
customMode = false
|
||||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||||
guard parts.count == 2 else { return }
|
||||
width = parts[0]
|
||||
height = parts[1]
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh rates this device can display, plus any stored custom value (see `SettingsOptions`).
|
||||
private var refreshChoices: [Int] {
|
||||
SettingsOptions.refreshRates(including: hz)
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
Picker("Speaker", selection: $speakerUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(outputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
}
|
||||
if !speakerUID.isEmpty,
|
||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||
Text("Unavailable device").tag(speakerUID)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||
#if os(macOS)
|
||||
Picker("Microphone", selection: $micUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(inputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
}
|
||||
if !micUID.isEmpty,
|
||||
!inputDevices.contains(where: { $0.uid == micUID }) {
|
||||
Text("Unavailable device").tag(micUID)
|
||||
}
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
#endif
|
||||
} header: {
|
||||
Text("Audio")
|
||||
} footer: {
|
||||
Text("Host audio plays through the speaker; the microphone feeds the "
|
||||
+ "host's virtual mic. System default follows macOS device changes. "
|
||||
+ "Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// iPad-only pointer-capture toggle: lock the mouse/trackpad for relative movement (games) vs
|
||||
/// forward an absolute cursor position (desktop). Empty on iPhone (no hardware-pointer lock —
|
||||
/// the mouse path there is always the absolute fallback).
|
||||
@ViewBuilder var pointerSection: some View {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
Section {
|
||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||
} header: {
|
||||
Text("Pointer")
|
||||
} footer: {
|
||||
Text("With a mouse or trackpad connected, lock the pointer and send relative "
|
||||
+ "movement — the expected behavior for games (mouse-look). Turn this off for "
|
||||
+ "desktop use to keep the pointer free and send its absolute position instead. "
|
||||
+ "The lock needs the stream full-screen and frontmost; it falls back to the "
|
||||
+ "absolute pointer automatically (Stage Manager, Slide Over). Finger touch is "
|
||||
+ "unaffected. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder var compositorSection: some View {
|
||||
Section {
|
||||
Picker("Compositor", selection: $compositor) {
|
||||
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Host compositor")
|
||||
} footer: {
|
||||
Text("Which compositor drives the virtual output on the host. A specific "
|
||||
+ "choice is honored only if that backend is available there — "
|
||||
+ "otherwise the host falls back to auto-detection.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var windowSection: some View {
|
||||
#if os(macOS)
|
||||
Section {
|
||||
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
||||
} header: {
|
||||
Text("Window")
|
||||
} footer: {
|
||||
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||
+ "list, so only the stream is fullscreen — not the picker.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
||||
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
||||
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
||||
// the controller test. Empty in release builds (no presenter UI; stage-2 always).
|
||||
@ViewBuilder var presenterSection: some View {
|
||||
#if DEBUG
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
Text("Stage 2 (default)").tag("stage2")
|
||||
Text("Stage 1 (debug)").tag("stage1")
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter · debug")
|
||||
} footer: {
|
||||
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD and "
|
||||
+ "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the "
|
||||
+ "system display layer; it freezes on a lost HEVC reference frame, so it's a debug "
|
||||
+ "fallback only. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var hdrSection: some View {
|
||||
Section {
|
||||
Picker("Video codec", selection: $codec) {
|
||||
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
||||
} header: {
|
||||
Text("Video quality")
|
||||
} footer: {
|
||||
Text("Codec is a preference — the host falls back if it can't encode the one you pick "
|
||||
+ "(and 10-bit/4:4:4 are HEVC-only). HDR requests a 10-bit BT.2020 PQ (HDR10) stream — "
|
||||
+ "it only engages when the host is sending HDR content AND this display supports HDR. "
|
||||
+ "4:4:4 requests full chroma (sharper text/UI, more bandwidth) — it only engages when "
|
||||
+ "this device can hardware-decode it AND the host opted in. Otherwise the stream stays "
|
||||
+ "8-bit 4:2:0 SDR. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var statisticsSection: some View {
|
||||
Section {
|
||||
Toggle("Show statistics overlay", isOn: $hudEnabled)
|
||||
Picker("Position", selection: $hudPlacement) {
|
||||
ForEach(HUDPlacement.allCases) { placement in
|
||||
Text(placement.label).tag(placement.rawValue)
|
||||
}
|
||||
}
|
||||
.disabled(!hudEnabled)
|
||||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text(Self.statisticsFooter)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var experimentalSection: some View {
|
||||
Section {
|
||||
Toggle("Show game library", isOn: $libraryEnabled)
|
||||
} header: {
|
||||
Text("Experimental")
|
||||
} footer: {
|
||||
Text("Adds a “Browse Library…” action to each host that lists its games "
|
||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
||||
+ "Works once you've paired with the host — the library is authorized by this "
|
||||
+ "device's certificate, with no extra host setup.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var controllersSection: some View {
|
||||
Section {
|
||||
if gamepads.controllers.isEmpty {
|
||||
Text("No controllers detected")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(gamepads.controllers) { controller in
|
||||
controllerRow(controller)
|
||||
}
|
||||
}
|
||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||
ForEach(controllerOptions, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
Picker("Controller type", selection: $gamepadType) {
|
||||
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
Button("Test Controller…") { showControllerTest = true }
|
||||
.disabled(gamepads.active == nil)
|
||||
.sheet(isPresented: $showControllerTest) { ControllerTestView() }
|
||||
#endif
|
||||
} header: {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
// The gamepad-UI blurb is appended here, not merged into the shared
|
||||
// `controllersFooter` constant — tvOS's `tvBody` reuses that exact string (line ~348)
|
||||
// for its own footer and has no such toggle to describe.
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(Self.controllersFooter)
|
||||
#if !os(tvOS)
|
||||
Text(Self.gamepadUIFooter)
|
||||
#endif
|
||||
}
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// SettingsView's footers and stateful helpers, used by both the section builders
|
||||
// (SettingsView+Sections.swift) and the per-platform bodies (SettingsView.swift). The option
|
||||
// LISTS live in SettingsOptions — they're shared with the gamepad settings screen too.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
extension SettingsView {
|
||||
// MARK: - Bitrate
|
||||
|
||||
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
||||
/// (a few Mbps … 3 Gbps) — linear would cram everything below 100 Mbps into the
|
||||
/// first pixels.
|
||||
private static let minSliderKbps = 2_000.0
|
||||
private static let maxSliderKbps = 3_000_000.0
|
||||
|
||||
static let bitrateFooter =
|
||||
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
|
||||
+ "to its supported range. Run a speed test from a host card's context menu to "
|
||||
+ "pick an informed value. Applies from the next session."
|
||||
|
||||
static let gigabitWarning =
|
||||
"Above 1 Gbps — test the network speed first (a host card's context menu → "
|
||||
+ "Test Network Speed…). A bitrate beyond what the link sustains causes loss "
|
||||
+ "and stutter."
|
||||
|
||||
/// `bitrateKbps == 0` is Automatic; switching to manual lands on the host default.
|
||||
var automaticBitrate: Binding<Bool> {
|
||||
Binding(
|
||||
get: { bitrateKbps == 0 },
|
||||
set: { bitrateKbps = $0 ? 0 : 20_000 })
|
||||
}
|
||||
|
||||
/// Slider position 0...1 ↔ kbps on the log scale, snapped to two significant figures
|
||||
/// so the readout shows round numbers instead of 47_322.
|
||||
var bitrateSlider: Binding<Double> {
|
||||
Binding(
|
||||
get: {
|
||||
let v = Double(bitrateKbps).clamped(Self.minSliderKbps, Self.maxSliderKbps)
|
||||
return log(v / Self.minSliderKbps)
|
||||
/ log(Self.maxSliderKbps / Self.minSliderKbps)
|
||||
},
|
||||
set: { pos in
|
||||
let raw = Self.minSliderKbps
|
||||
* pow(Self.maxSliderKbps / Self.minSliderKbps, pos)
|
||||
let mag = pow(10, floor(log10(raw)) - 1)
|
||||
bitrateKbps = Int((raw / mag).rounded() * mag)
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
static var statisticsFooter: String {
|
||||
let base = "The overlay shows resolution, frame rate, throughput and latency while "
|
||||
+ "streaming, in the chosen corner."
|
||||
#if os(macOS) || os(iOS)
|
||||
return base + " Toggle it any time with ⌘⇧S."
|
||||
#else
|
||||
return base
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Controllers
|
||||
|
||||
static let controllersFooter =
|
||||
"One controller is forwarded to the host, as player 1 — Automatic picks the most "
|
||||
+ "recently connected one. The type is the virtual pad the host creates: Automatic "
|
||||
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
|
||||
+ "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply "
|
||||
+ "from the next session. Two identical controllers may swap a manual selection "
|
||||
+ "after reconnecting."
|
||||
|
||||
#if !os(tvOS)
|
||||
static let gamepadUIFooter =
|
||||
"When a controller is connected, the host list and game library switch to a "
|
||||
+ "controller-friendly layout — larger focus targets, controller-navigable settings, "
|
||||
+ "and a swipeable cover browser for the library. Turn this off to always use the "
|
||||
+ "standard layout. (The system may still move basic focus with a controller "
|
||||
+ "connected even with this off — that's outside the app's control.)"
|
||||
#endif
|
||||
|
||||
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
||||
var controllerOptions: [(label: String, tag: String)] {
|
||||
SettingsOptions.controllerOptions(gamepads)
|
||||
}
|
||||
|
||||
func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(controller.name)
|
||||
HStack(spacing: 8) {
|
||||
if !controller.isExtended {
|
||||
Text(controller.productCategory)
|
||||
}
|
||||
if controller.hasAdaptiveTriggers {
|
||||
Image(systemName: "r2.button.roundedtop.horizontal")
|
||||
}
|
||||
if controller.hasLight {
|
||||
Image(systemName: "lightbulb.fill")
|
||||
}
|
||||
if controller.hasMotion {
|
||||
Image(systemName: "gyroscope")
|
||||
}
|
||||
if controller.hasHaptics {
|
||||
Image(systemName: "waveform")
|
||||
}
|
||||
if let level = controller.batteryLevel {
|
||||
Text("\(Int(level * 100))%")
|
||||
if controller.isCharging {
|
||||
Image(systemName: "bolt.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if gamepads.active?.id == controller.id {
|
||||
Text("In use")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(.green.opacity(0.2)))
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fillFromMainScreen() {
|
||||
#if os(macOS)
|
||||
guard let screen = NSScreen.main else { return }
|
||||
let scale = screen.backingScaleFactor
|
||||
width = Int(screen.frame.width * scale)
|
||||
height = Int(screen.frame.height * scale)
|
||||
hz = screen.maximumFramesPerSecond
|
||||
#else
|
||||
// nativeBounds is portrait-oriented pixels — streams are landscape.
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
width = Int(max(bounds.width, bounds.height))
|
||||
height = Int(min(bounds.width, bounds.height))
|
||||
hz = UIScreen.main.maximumFramesPerSecond
|
||||
#if os(iOS)
|
||||
// The native mode is the "This device" wheel row, so leave Custom mode if it was on.
|
||||
customMode = false
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||
// there is no scaling anywhere in the pipeline.
|
||||
//
|
||||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||
// `audioSection`, …) are shared across all three so a setting is defined exactly once — they
|
||||
// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct SettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presenter) var presenter = "stage2"
|
||||
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.enable444) var enable444 = true
|
||||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
||||
@AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject var gamepads = GamepadManager.shared
|
||||
#if !os(tvOS)
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State var showControllerTest = false
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var settingsSelection: SettingsCategory?
|
||||
// Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen
|
||||
// — not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts
|
||||
// .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible.
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
|
||||
// Sticky once the wheel lands on "Custom…", so editing a width/height that briefly equals a
|
||||
// preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even
|
||||
// when this is false (see `isCustomResolution`), so it survives relaunches without persisting.
|
||||
@State var customMode = false
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||||
@State var outputDevices: [AudioDevice] = []
|
||||
@State var inputDevices: [AudioDevice] = []
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on
|
||||
/// General via `onAppear`). The screenshot harness passes an explicit category so the captured
|
||||
/// shot opens on a real settings page (a populated detail) rather than the bare category list.
|
||||
init(initialCategory: SettingsCategory? = nil) {
|
||||
_settingsSelection = State(initialValue: initialCategory)
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
||||
// miserable and the inline field chrome fights the focus system). Modes are
|
||||
// preset pickers that push selection lists like the system Settings app.
|
||||
tvBody
|
||||
#elseif os(macOS)
|
||||
macBody
|
||||
#else
|
||||
iosBody
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - macOS: tabbed preferences
|
||||
|
||||
#if os(macOS)
|
||||
private var macBody: some View {
|
||||
TabView {
|
||||
Form {
|
||||
streamModeSection
|
||||
compositorSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
windowSection
|
||||
statisticsSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Display", systemImage: "display") }
|
||||
|
||||
Form {
|
||||
audioSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
outputDevices = AudioDevices.outputs()
|
||||
inputDevices = AudioDevices.inputs()
|
||||
}
|
||||
.tabItem { Label("Audio", systemImage: "speaker.wave.2") }
|
||||
|
||||
Form {
|
||||
controllersSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||||
|
||||
Form {
|
||||
experimentalSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||||
|
||||
AcknowledgementsView()
|
||||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
}
|
||||
.frame(width: 480, height: 460)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - iOS / iPadOS: adaptive split view
|
||||
|
||||
#if os(iOS)
|
||||
private var iosBody: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
List(selection: $settingsSelection) {
|
||||
ForEach(SettingsCategory.allCases) { category in
|
||||
// On iPhone the split view collapses to a push list, but a selection List
|
||||
// draws no disclosure indicator of its own — add one in compact width for the
|
||||
// expected drill-in affordance. On iPad the selected row highlights instead, so
|
||||
// the chevron is omitted there.
|
||||
HStack {
|
||||
Label(category.title, systemImage: category.symbol)
|
||||
if horizontalSizeClass == .compact {
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
// Purely a drill-in affordance — the row's button trait already
|
||||
// conveys "opens"; keep it out of the VoiceOver announcement.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.tag(category)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
// NavigationSplitView hosts the detail in its own navigation context (its title bar),
|
||||
// so no inner NavigationStack — that would double the bar on iPad. On iPhone the split
|
||||
// view collapses to one stack and pushes this when a row is tapped. `?? .general` only
|
||||
// backs the brief pre-selection window; the list never auto-pushes on a nil selection.
|
||||
settingsDetail(settingsSelection ?? .general)
|
||||
// Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the
|
||||
// iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the
|
||||
// sidebar is showing, its Done is the only one — so this stays hidden to avoid two.
|
||||
.toolbar {
|
||||
if horizontalSizeClass == .compact || columnVisibility == .detailOnly {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if horizontalSizeClass == .regular, settingsSelection == nil {
|
||||
settingsSelection = .general
|
||||
}
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
// A regular→regular launch sets the default above; this catches a compact→regular change
|
||||
// (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in.
|
||||
.onChange(of: horizontalSizeClass) { _, newValue in
|
||||
if newValue == .regular, settingsSelection == nil {
|
||||
settingsSelection = .general
|
||||
}
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsDetail(_ category: SettingsCategory) -> some View {
|
||||
switch category {
|
||||
case .general:
|
||||
Form {
|
||||
streamModeSection
|
||||
pointerSection
|
||||
compositorSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .display:
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
statisticsSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Display")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .audio:
|
||||
Form { audioSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Audio")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .controllers:
|
||||
Form { controllersSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Controllers")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .advanced:
|
||||
Form { experimentalSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Advanced")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .about:
|
||||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||||
// the large title from the "Settings" sidebar root).
|
||||
AcknowledgementsView()
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - tvOS
|
||||
|
||||
#if os(tvOS)
|
||||
private static let presets: [(label: String, tag: String)] = [
|
||||
("720p @ 60", "1280x720x60"),
|
||||
("1080p @ 60", "1920x1080x60"),
|
||||
("4K @ 60", "3840x2160x60"),
|
||||
]
|
||||
|
||||
private var modeTag: Binding<String> {
|
||||
Binding(
|
||||
get: { "\(width)x\(height)x\(hz)" },
|
||||
set: { tag in
|
||||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||||
guard parts.count == 3 else { return }
|
||||
width = parts[0]
|
||||
height = parts[1]
|
||||
hz = parts[2]
|
||||
})
|
||||
}
|
||||
|
||||
private var hudEnabledTag: Binding<String> {
|
||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var hdrEnabledTag: Binding<String> {
|
||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var tvBody: some View {
|
||||
let currentTag = "\(width)x\(height)x\(hz)"
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
let nativeTag = "\(Int(max(bounds.width, bounds.height)))x"
|
||||
+ "\(Int(min(bounds.width, bounds.height)))x\(UIScreen.main.maximumFramesPerSecond)"
|
||||
var options = Self.presets
|
||||
if !options.contains(where: { $0.tag == nativeTag }) {
|
||||
options.insert(("This TV (native)", nativeTag), at: 0)
|
||||
}
|
||||
if !options.contains(where: { $0.tag == currentTag }) {
|
||||
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
||||
}
|
||||
return ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||
TVSelectionRow(
|
||||
title: "Bitrate",
|
||||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||||
selection: $bitrateKbps)
|
||||
TVSelectionRow(
|
||||
title: "Audio channels",
|
||||
options: SettingsOptions.audioChannels,
|
||||
selection: $audioChannels)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "Compositor", options: SettingsOptions.compositors,
|
||||
selection: $compositor)
|
||||
#if DEBUG
|
||||
TVSelectionRow(
|
||||
title: "Presenter (debug)",
|
||||
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||||
selection: $presenter)
|
||||
#endif
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||
Text("The host creates a virtual output at exactly this mode — native "
|
||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||
+ "is honored only if available on the host.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
TVSelectionRow(
|
||||
title: "Statistics overlay",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag)
|
||||
TVSelectionRow(
|
||||
title: "Statistics position", options: SettingsOptions.hudPlacements,
|
||||
selection: $hudPlacement)
|
||||
ForEach(gamepads.controllers) { controller in
|
||||
controllerRow(controller)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "Use controller", options: controllerOptions,
|
||||
selection: $gamepads.preferredID)
|
||||
TVSelectionRow(
|
||||
title: "Controller type", options: SettingsOptions.padTypes,
|
||||
selection: $gamepadType)
|
||||
Text(Self.controllersFooter)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(60)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.onAppear {
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user