apple / swift (pull_request) Successful in 1m30s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 4m16s
ci / rust-arm64 (pull_request) Successful in 3m20s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 2m7s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 1m55s
ci / rust (pull_request) Successful in 10m36s
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26 opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for one press — the local one covering the stream. Two cross-client tier-P settings, zero wire changes: - system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the same physical press no matter what. - guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's guide, down until release — held on, that's the host's long-press, which opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered on release with its up TAP_PRESS (50ms) behind, because per-transition sends fold into seq'd GamepadState snapshots and a back-to-back pair can coalesce into no press at all. A Select inside a combo (the escape chord) passes through untouched. Auto arms it only where the raw press can't reach the host cleanly: gamescope, iOS/iPadOS, tvOS. The same SelectGesture rules live in pf-client-core (pure state machine + unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and Android's GamepadRouter. Settings rows on every surface (GTK, WinUI, console UI, Decky, Apple x2, Android x2) with profile plumbing throughout. punktfunk-session grows a control socket ($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one runtime path a flatpak and the host see identically): 'guide'/'qam' verbs inject synthetic taps. The Decky panel gains a Host menus section (visible while the client runs) whose buttons press the host's Steam/QAM and close the local menu so the host's shows through. iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the class needs the Xcode 27 SDK to compile). Docs: input, client-settings, steam-deck. Design: punktfunk-planning design/system-buttons-routing.md. Gates: docker clippy --all-targets --locked -D warnings + tests (pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47), cargo fmt --all --check, swift build (macOS), gradle kit+app compile, decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
631 lines
32 KiB
Swift
631 lines
32 KiB
Swift
// The gamepad-driven settings screen (iOS/iPadOS/macOS/tvOS): 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.
|
||
//
|
||
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an
|
||
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
|
||
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
|
||
// Pins are presentation only: never the host's default binding, never the profile itself —
|
||
// profiles are created and edited in the standard interface (and can't be on tvOS, whose
|
||
// per-device catalog the detail strings are honest about).
|
||
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
#if os(iOS) || os(macOS) || os(tvOS)
|
||
import GameController
|
||
#if os(iOS)
|
||
import CoreHaptics
|
||
#endif
|
||
|
||
struct GamepadSettingsView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||
/// itself (ContentView owns the instance).
|
||
@ObservedObject var store: HostStore
|
||
@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.gamepadForwarding) private var gamepadForwarding = true
|
||
@AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto"
|
||
@AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto"
|
||
@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 = false
|
||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||
@AppStorage(DefaultsKey.echoCancel) private var echoCancel = true
|
||
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
|
||
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||
= StatsVerbosity.current.rawValue
|
||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
|
||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||
SettingsOptions.presentPriorityDefault
|
||
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
||
#if os(macOS)
|
||
@AppStorage(DefaultsKey.windowedSafePresent) private var windowedSafePresent = true
|
||
#endif
|
||
#if os(iOS)
|
||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||
#endif
|
||
@ObservedObject private var gamepads = GamepadManager.shared
|
||
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the
|
||
/// Profiles rows re-derive from it each render, so a rename/delete made in the standard
|
||
/// interface shows up live.
|
||
@ObservedObject private var profiles = ProfileStore.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?
|
||
/// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per
|
||
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
|
||
@State private var pinTarget: StreamProfile?
|
||
/// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the
|
||
/// changed value slides in from, so the animation follows the user's motion.
|
||
@State private var lastAdjustDelta = 1
|
||
|
||
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: { back() }
|
||
) { row, focused in
|
||
rowView(row, focused: focused)
|
||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||
.padding(.horizontal, 24)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.safeAreaInset(edge: .top, spacing: 0) {
|
||
Text(title)
|
||
.font(.geist(gamepadTitleSize(compact: compact), .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(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||
.foregroundStyle(.white.opacity(0.55))
|
||
.lineLimit(2, reservesSpace: true)
|
||
.animation(.smooth(duration: 0.2), value: focusID)
|
||
GamepadHintBar(hints: hints)
|
||
}
|
||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||
.padding(.leading, compact ? 12 : 18)
|
||
.padding(.trailing, 22)
|
||
.padding(.bottom, compact ? 12 : 18)
|
||
.padding(.top, compact ? 6 : 10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background { GamepadTrayScrim(edge: .bottom) }
|
||
}
|
||
// No aurora here — the settings read as clean Liquid Glass over a quiet dark base, so the
|
||
// glass rows are the only material on the screen.
|
||
.background { GamepadFormBackground() }
|
||
.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: GamepadFormMetrics.closeFont, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||
.glassBackground(Circle(), interactive: true)
|
||
.contentShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
#if !os(tvOS)
|
||
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||
#endif
|
||
.accessibilityLabel("Close settings")
|
||
}
|
||
|
||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||
/// layer the row list currently is.
|
||
private var title: String {
|
||
pinTarget.map { "Pin “\($0.name)”" } ?? "Settings"
|
||
}
|
||
|
||
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
|
||
/// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
|
||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||
private var hints: [GamepadHint] {
|
||
guard pinTarget != nil else {
|
||
return [
|
||
.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"),
|
||
]
|
||
}
|
||
guard !store.hosts.isEmpty else {
|
||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
|
||
}
|
||
return [
|
||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
|
||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
|
||
]
|
||
}
|
||
|
||
/// B peels one layer: the pin picker back to the settings rows — focus returning to the
|
||
/// profile row it came from — then the screen itself.
|
||
private func back() {
|
||
if let profile = pinTarget {
|
||
pinTarget = nil
|
||
focusID = "profile-\(profile.id)"
|
||
} else {
|
||
dismiss()
|
||
}
|
||
}
|
||
|
||
// MARK: - Row rendering
|
||
|
||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||
let m = GamepadFormMetrics.self
|
||
return VStack(alignment: .leading, spacing: 6) {
|
||
if let header = row.header {
|
||
Text(header)
|
||
.font(.geist(m.headerFont, .semibold, relativeTo: .caption))
|
||
.tracking(1.4)
|
||
.foregroundStyle(.white.opacity(0.45))
|
||
.padding(.leading, m.rowHPad)
|
||
.padding(.top, 14)
|
||
}
|
||
HStack(spacing: 14) {
|
||
Image(systemName: row.icon)
|
||
.font(.system(size: m.iconFont))
|
||
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
|
||
.frame(width: m.iconWidth)
|
||
Text(row.label)
|
||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(1)
|
||
Spacer(minLength: 12)
|
||
HStack(spacing: 9) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||
// Keyed by the value so a change slides the new option in instead of
|
||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||
// Deliberately not `.push`: that travels the whole container width, loud
|
||
// and visibly outside the row. The ZStack is the stable home the
|
||
// removed/inserted texts transition within.
|
||
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
|
||
ZStack {
|
||
Text(row.value)
|
||
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
|
||
.foregroundStyle(focused ? .white : .white.opacity(0.6))
|
||
.lineLimit(1)
|
||
.id(row.value)
|
||
.transition(.asymmetric(
|
||
insertion: .offset(x: slide).combined(with: .opacity),
|
||
removal: .offset(x: -slide).combined(with: .opacity)))
|
||
}
|
||
.animation(.smooth(duration: 0.22), value: row.value)
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||
}
|
||
}
|
||
.padding(.horizontal, m.rowHPad)
|
||
.padding(.vertical, m.rowVPad)
|
||
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||
.consoleGlass(
|
||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||
tint: focused ? Color.brand.opacity(0.30) : nil,
|
||
interactive: focused)
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), 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
|
||
/// Whether left/right means anything here — false hides the value's chevrons (the
|
||
/// Profiles rows navigate, and the placeholder rows do nothing at all).
|
||
var adjustable = true
|
||
/// 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 {
|
||
lastAdjustDelta = delta
|
||
return rows.first { $0.id == id }?.adjust(delta) ?? false
|
||
}
|
||
|
||
private func activate(id: String) {
|
||
lastAdjustDelta = 1 // A always cycles forward
|
||
rows.first { $0.id == id }?.activate()
|
||
}
|
||
|
||
private var rows: [Row] {
|
||
// The pin picker replaces the whole list while it's up — same screen, one layer deeper,
|
||
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
|
||
if let profile = pinTarget { return pinRows(for: profile) }
|
||
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)
|
||
var list: [Row] = [
|
||
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 },
|
||
toggleRow(
|
||
id: "autoWake", icon: "power", label: "Auto-wake on connect",
|
||
detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before "
|
||
+ "streaming. Off connects straight through.",
|
||
value: $autoWakeEnabled),
|
||
|
||
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: "presentPriority", icon: "rectangle.stack", label: "Prioritize",
|
||
detail: "Lowest latency shows each frame the moment the display can take it; "
|
||
+ "Smoothness buffers a few frames to even out network hiccups. Applies "
|
||
+ "from the next session.",
|
||
options: SettingsOptions.presentPriorities, current: presentPriority
|
||
) { presentPriority = $0 },
|
||
choiceRow(
|
||
id: "smoothBuffer", icon: "square.stack.3d.up", label: "Smoothness buffer",
|
||
detail: "How many frames Smoothness holds — each adds about a refresh of "
|
||
+ "display latency and absorbs about a refresh of jitter. Only applies "
|
||
+ "when prioritizing smoothness.",
|
||
options: SettingsOptions.smoothBuffers(refreshHz: hz), current: smoothBuffer
|
||
) { smoothBuffer = $0 },
|
||
|
||
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),
|
||
toggleRow(
|
||
id: "echoCancel", icon: "waveform", label: "Echo cancellation",
|
||
detail: "Cancel the audio this device plays out of the mic signal — stops "
|
||
+ "speaker setups feeding the game back to the host.",
|
||
value: $echoCancel),
|
||
|
||
toggleRow(
|
||
id: "padForward", header: "Controller", icon: "gamecontroller",
|
||
label: "Forward controllers",
|
||
detail: "Send this device's controllers to the host. Turn it off when your "
|
||
+ "controller already reaches the host another way — USB passthrough such "
|
||
+ "as VirtualHere — so games don't see two of them.",
|
||
value: $gamepadForwarding),
|
||
choiceRow(
|
||
id: "pad", 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 },
|
||
choiceRow(
|
||
id: "systemButtons", icon: "house.circle", label: "Guide button",
|
||
detail: "Where the guide (Xbox/PS) and share presses go while streaming — "
|
||
+ "Automatic sends them to the host whenever this device delivers them.",
|
||
options: SettingsOptions.systemButtons, current: systemButtons
|
||
) { systemButtons = $0 },
|
||
choiceRow(
|
||
id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide",
|
||
detail: "Hold Select alone to press the host's guide button — keep holding "
|
||
+ "for a Gaming-Mode host's quick-access menu. A tap still goes through.",
|
||
options: SettingsOptions.guideGestures, current: guideGesture
|
||
) { guideGesture = $0 },
|
||
|
||
choiceRow(
|
||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||
detail: "How much to show while streaming — Compact is a one-line pill, "
|
||
+ "Detailed adds the latency stage breakdown.",
|
||
options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw
|
||
) { statsVerbosityRaw = $0 },
|
||
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")).",
|
||
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),
|
||
]
|
||
#if os(macOS)
|
||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||
// the Video group) — macOS only, mirroring the touch SettingsView's Presentation row
|
||
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
|
||
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
|
||
list.insert(
|
||
toggleRow(
|
||
id: "windowedSafePresent", icon: "macwindow.badge.plus",
|
||
label: "Safe windowed presentation",
|
||
detail: "Windowed streams present in step with the compositor — avoids a "
|
||
+ "macOS display-driver crash on high-refresh displays, at a small "
|
||
+ "latency cost. Fullscreen always uses the fastest path.",
|
||
value: $windowedSafePresent),
|
||
at: at + 1)
|
||
}
|
||
#endif
|
||
#if os(iOS)
|
||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||
// practice: hidden where the device itself can't play haptics (iPad).
|
||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics,
|
||
let at = list.firstIndex(where: { $0.id == "padType" }) {
|
||
list.insert(
|
||
toggleRow(
|
||
id: "deviceRumble", icon: "iphone.radiowaves.left.and.right",
|
||
label: "Rumble on this iPhone",
|
||
detail: "Also play player 1's rumble on the phone's own Taptic Engine — "
|
||
+ "for clip-on pads without rumble motors.",
|
||
value: $rumbleOnDevice),
|
||
at: at + 1)
|
||
}
|
||
#endif
|
||
return list + profileRows
|
||
}
|
||
|
||
// MARK: - Profiles (§5.2a)
|
||
|
||
/// The trailing Profiles section: one row per catalog profile, its value how many saved
|
||
/// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that — this surface
|
||
/// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so
|
||
/// left/right is a boundary thud, not an editor.
|
||
private var profileRows: [Row] {
|
||
guard !profiles.profiles.isEmpty else {
|
||
return [Row(
|
||
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
|
||
label: "No profiles yet", value: "",
|
||
detail: emptyCatalogDetail,
|
||
adjustable: false,
|
||
adjust: { _ in false }, activate: {})]
|
||
}
|
||
return profiles.profiles.enumerated().map { i, profile in
|
||
let pins = store.hosts
|
||
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
|
||
return Row(
|
||
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
|
||
icon: "slider.horizontal.3", label: profile.name,
|
||
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
|
||
detail: profileDetail,
|
||
adjustable: false,
|
||
adjust: { _ in false },
|
||
activate: {
|
||
// Focus lands on the picker's first row — the focus list's reconcile
|
||
// follows this id when the row set swaps underneath it.
|
||
focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts"
|
||
pinTarget = profile
|
||
})
|
||
}
|
||
}
|
||
|
||
/// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows'
|
||
/// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a
|
||
/// boundary thud). Writes ride `HostStore.setPinned` — pin appends, unpin removes — and
|
||
/// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a).
|
||
private func pinRows(for profile: StreamProfile) -> [Row] {
|
||
guard !store.hosts.isEmpty else {
|
||
return [Row(
|
||
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
|
||
value: "",
|
||
detail: "Pair with a host first, then pin this profile to it.",
|
||
adjustable: false,
|
||
adjust: { _ in false }, activate: {})]
|
||
}
|
||
return store.hosts.map { host in
|
||
let hostID = host.id
|
||
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
|
||
return Row(
|
||
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
|
||
label: host.displayName,
|
||
value: pinned ? "Pinned" : "Off",
|
||
detail: "A pinned profile appears as its own card on the host — one press "
|
||
+ "connects with it.",
|
||
adjust: { delta in
|
||
let target = delta > 0
|
||
guard pinned != target else { return false }
|
||
store.setPinned(hostID, profileID: profile.id, pinned: target)
|
||
return true
|
||
},
|
||
activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) })
|
||
}
|
||
}
|
||
|
||
/// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group
|
||
/// suite — nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV
|
||
/// user at a "standard interface" would promise profiles that can never arrive there.
|
||
private var profileDetail: String {
|
||
#if os(tvOS)
|
||
return "Pin this profile to a host and it appears as its own card on the home screen — "
|
||
+ "one press connects with it."
|
||
#else
|
||
return "Pin this profile to a host and it appears as its own card — one press connects "
|
||
+ "with it. Profiles are created and edited in Punktfunk's standard interface."
|
||
#endif
|
||
}
|
||
|
||
/// What the empty catalog's placeholder explains — again honest on tvOS, where profiles
|
||
/// cannot be created (on the device or anywhere that would reach its per-device catalog).
|
||
private var emptyCatalogDetail: String {
|
||
#if os(tvOS)
|
||
return "Profiles bundle stream settings for different uses. Creating them isn't "
|
||
+ "available on Apple TV yet."
|
||
#else
|
||
return "Profiles bundle stream settings for different uses. Create them in Punktfunk's "
|
||
+ "standard interface, then pin them here as one-press connect cards."
|
||
#endif
|
||
}
|
||
|
||
/// 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
|