0e60e58cab
ci / docs-site (push) Successful in 52s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 21s
apple / swift (push) Successful in 1m24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / bench (push) Successful in 5m53s
release / apple (push) Successful in 9m19s
deb / build-publish (push) Successful in 12m27s
arch / build-publish (push) Successful in 12m52s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 13m18s
apple / screenshots (push) Successful in 6m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m51s
android / android (push) Successful in 18m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m15s
ci / rust (push) Successful in 27m38s
Task 1 (latency): the transactional present now commits from the RENDER
thread inside an explicit CATransaction + flush() instead of hopping to
main. The present harness (2026-07-21, 240 Hz Studio, full-size window)
measured the main hop batching presents at runloop-iteration rate when
main is busy — an active implicit transaction NESTS the explicit one —
which is the field's presents=55 @ fps=240 / display_p50 18.6 ms.
Off-main commits measured immune to main churn: glass p50 ~10 ms,
cadence a clean 4.17 ms at 240. flush() is load-bearing: without it the
render thread's own implicit transaction (drawableSize/colour writes)
swallows the explicit commit and NOTHING reaches glass — the harness
reproduced the exact freeze (every present dropped). Legacy main-hop
kept as PUNKTFUNK_TXN_PRESENT=main for field A/B. New pf-windowed
Console line (subsystem io.unom.punktfunk, category presenter)
decomposes the issue side per second.
Task 1 lever D: the f407f418 IOSurface contents-swap path is
resurrected format-aware as WindowedPresentMode.surface — bgra8 SDR /
rgba16Float+PQ-tagged HDR pool, swap committed off-main from the
completion handler; EDR anchored by the metal layer underneath.
Env-only prototype (PUNKTFUNK_WINDOWED_PRESENT=surface) until the HDR
composite is eyeballed on glass.
Task 2 (setting): punktfunk.windowedSafePresent (default ON =
transactional mitigation; OFF = the fast async path whose panic the
saga is about — the caption says so in plain words). Rows in the touch
Settings (Presentation) and gamepad settings, macOS-only.
PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface overrides for dev
A/B. maximumDrawableCount stays 3 — the harness measured 2 starving the
render loop (172/s) and worsening glass p50.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
446 lines
21 KiB
Swift
446 lines
21 KiB
Swift
// App settings. The host creates a virtual output at exactly the chosen size/refresh; the only
|
||
// deliberate resample is the opt-in Render Scale (the host renders at size × scale and this device
|
||
// downscales — supersampling for sharpness, or under-rendering for a lighter host/link).
|
||
//
|
||
// Navigation differs per platform, but all three follow the same category map (General =
|
||
// session/app behavior, Display = everything about the picture, Input, Audio, Controllers,
|
||
// About — see SettingsCategory): 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 in the same order. The individual sections
|
||
// (`resolutionSection`, `audioSection`, …) are shared across all three so a setting is defined
|
||
// exactly once — they live in SettingsView+Sections.swift, with their helpers (including the
|
||
// per-field `described` caption idiom) 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
|
||
// Opt-in (default OFF): the explicit mode below is used and never auto-resized. When ON, a
|
||
// windowed session instead streams at the window's native pixels (1:1, no scaling) so it stays
|
||
// pixel-exact rather than the presenter resampling a fixed-mode frame into the window.
|
||
@AppStorage(DefaultsKey.matchWindow) var matchWindow = false
|
||
// Render-resolution multiplier: the host renders/encodes at chosen-resolution × this, and the
|
||
// presenter downscales (> 1 = supersampling for sharpness) or upscales (< 1 = a lighter host /
|
||
// link). 1.0 = Native (the prior behaviour).
|
||
@AppStorage(DefaultsKey.renderScale) var renderScale = 1.0
|
||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||
@AppStorage(DefaultsKey.presentPriority) var presentPriority =
|
||
SettingsOptions.presentPriorityDefault
|
||
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
||
#if os(macOS)
|
||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||
@AppStorage(DefaultsKey.windowedSafePresent) var windowedSafePresent = true
|
||
#endif
|
||
#if !os(tvOS)
|
||
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
||
#endif
|
||
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
||
@AppStorage(DefaultsKey.enable444) var enable444 = false
|
||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
|
||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
||
// The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs
|
||
// the legacy-hudEnabled migration (same pattern as ContentView/StreamCommands).
|
||
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
|
||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||
@ObservedObject var gamepads = GamepadManager.shared
|
||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||
@AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true
|
||
@AppStorage(DefaultsKey.backgroundKeepAlive) var backgroundKeepAlive = false
|
||
@AppStorage(DefaultsKey.backgroundTimeoutMinutes) var backgroundTimeoutMinutes = 10
|
||
#if !os(tvOS)
|
||
// Keyboard & mouse forwarding (macOS + a hardware keyboard/mouse on iPad). Invert-scroll flips
|
||
// both wheel axes; modifier-layout relocates the ⌥/⌘ → Alt/Super roles by physical position.
|
||
@AppStorage(DefaultsKey.invertScroll) var invertScroll = false
|
||
@AppStorage(DefaultsKey.modifierLayout) var modifierLayout = ModifierLayout.mac.rawValue
|
||
#endif
|
||
#if DEBUG && !os(tvOS)
|
||
@State var showControllerTest = false
|
||
#endif
|
||
#if os(iOS)
|
||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||
@AppStorage(DefaultsKey.rumbleOnDevice) var rumbleOnDevice = false
|
||
// 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 = ""
|
||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||
@State var outputDevices: [AudioDevice] = []
|
||
@State var inputDevices: [AudioDevice] = []
|
||
// Input channels of the selected mic — drives the "Microphone channel" picker, which only
|
||
// appears for a multi-channel interface (>1). 0 until the Audio tab loads it.
|
||
@State var micChannelCount = 0
|
||
#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 {
|
||
// Tab map mirrors SettingsCategory: General = session/app behavior, Display = the whole
|
||
// picture (resolution lives here), Input = keyboard & mouse.
|
||
TabView {
|
||
Form {
|
||
sessionSection
|
||
overlaySection
|
||
librarySection
|
||
}
|
||
.formStyle(.grouped)
|
||
.tabItem { Label("General", systemImage: "gearshape") }
|
||
|
||
Form {
|
||
resolutionSection
|
||
qualitySection
|
||
presentationSection
|
||
hostOutputSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.tabItem { Label("Display", systemImage: "display") }
|
||
|
||
Form {
|
||
inputSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.tabItem { Label("Input", systemImage: "keyboard") }
|
||
|
||
Form {
|
||
audioSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.onAppear {
|
||
outputDevices = AudioDevices.outputs()
|
||
inputDevices = AudioDevices.inputs()
|
||
micChannelCount = AudioDevices.inputChannelCount(forUID: micUID)
|
||
}
|
||
.onChange(of: micUID) { _, newUID in
|
||
// A different mic → different channel count; drop a now-out-of-range pin to Auto.
|
||
micChannelCount = AudioDevices.inputChannelCount(forUID: newUID)
|
||
if micChannel > micChannelCount { micChannel = 0 }
|
||
}
|
||
.tabItem { Label("Audio", systemImage: "speaker.wave.2") }
|
||
|
||
Form {
|
||
controllersSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.onAppear {
|
||
gamepads.refresh()
|
||
gamepads.startDiscovery()
|
||
}
|
||
.onDisappear { gamepads.stopDiscovery() }
|
||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||
|
||
AcknowledgementsView()
|
||
.tabItem { Label("About", systemImage: "info.circle") }
|
||
}
|
||
.frame(width: 500, height: 520)
|
||
}
|
||
#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 {
|
||
sessionSection
|
||
overlaySection
|
||
librarySection
|
||
}
|
||
.formStyle(.grouped)
|
||
.navigationTitle("General")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .display:
|
||
Form {
|
||
resolutionSection
|
||
qualitySection
|
||
presentationSection
|
||
hostOutputSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Display")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .input:
|
||
Form {
|
||
pointerSection
|
||
inputSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Input")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .audio:
|
||
Form { audioSection }
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Audio")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .controllers:
|
||
Form { controllersSection }
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Controllers")
|
||
.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 hdrEnabledTag: Binding<String> {
|
||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||
}
|
||
|
||
/// The gamepad-UI switch as an on/off row (same shape as HDR above) — the escape hatch back
|
||
/// to this focus-engine home for someone who prefers it with a controller connected.
|
||
private var gamepadUIEnabledTag: Binding<String> {
|
||
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
|
||
}
|
||
|
||
private var autoWakeEnabledTag: Binding<String> {
|
||
Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" })
|
||
}
|
||
|
||
/// One cluster caption, TV-legible — the 10-foot analogue of the touch/desktop per-row
|
||
/// `described` captions (per-row text doesn't scale to TV type sizes).
|
||
private func tvCaption(_ text: String) -> some View {
|
||
Text(text)
|
||
.font(.geist(20, relativeTo: .caption))
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.top, 8)
|
||
}
|
||
|
||
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)
|
||
}
|
||
// Row order mirrors the touch/desktop category map: Display (mode → quality →
|
||
// presentation → host output), then Audio, General, Statistics, Controllers — with one
|
||
// short caption per cluster (per-row captions don't scale to 10-foot type sizes).
|
||
return ScrollView {
|
||
VStack(spacing: 16) {
|
||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||
TVSelectionRow(
|
||
title: "Render scale",
|
||
options: RenderScale.presets.map { (label: RenderScale.label($0), tag: $0) },
|
||
selection: $renderScale)
|
||
TVSelectionRow(
|
||
title: "Bitrate",
|
||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||
selection: $bitrateKbps)
|
||
if bitrateKbps > 1_000_000 {
|
||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||
.foregroundStyle(.orange)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
TVSelectionRow(
|
||
title: "10-bit HDR",
|
||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||
TVSelectionRow(
|
||
title: "Prioritize",
|
||
options: SettingsOptions.presentPriorities,
|
||
selection: $presentPriority)
|
||
if presentPriority == "smooth" {
|
||
TVSelectionRow(
|
||
title: "Smoothness buffer",
|
||
options: SettingsOptions.smoothBuffers(refreshHz: hz),
|
||
selection: $smoothBuffer)
|
||
}
|
||
TVSelectionRow(
|
||
title: "Compositor", options: SettingsOptions.compositors,
|
||
selection: $compositor)
|
||
tvCaption("The host drives a real output at exactly the chosen mode. "
|
||
+ "\(Self.bitrateFooter) Lowest latency shows frames immediately; "
|
||
+ "Smoothness buffers a few to even out network hiccups. A specific "
|
||
+ "compositor is honored only if available on the host.")
|
||
TVSelectionRow(
|
||
title: "Audio channels",
|
||
options: SettingsOptions.audioChannels,
|
||
selection: $audioChannels)
|
||
TVSelectionRow(
|
||
title: "Auto-wake on connect",
|
||
options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag)
|
||
tvCaption("Auto-wake sends Wake-on-LAN to a sleeping saved host and waits for "
|
||
+ "it before streaming.")
|
||
TVSelectionRow(
|
||
title: "Statistics overlay",
|
||
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||
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)
|
||
TVSelectionRow(
|
||
title: "Gamepad-optimized browsing",
|
||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||
tvCaption(Self.controllersFooter)
|
||
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
|
||
}
|