One settings surface, two layers. A scope switcher at the top of the Mac window and the iOS sidebar swaps the whole screen between Default settings and one profile's overrides; the section builders stay the single definition of every row and just change which layer their binding reads and writes (design §5.1). A parallel profile editor would have drifted from this one field by field, and the revamp's captions and curation would have had to be written twice. `SettingsFields` is where a row's three faces meet — the UserDefaults key its global lives under, the overlay slot an override lives in, and the name a reset carries. Keeping them in one place is what stops a row from writing an override the reset button can't find. Every row shows the EFFECTIVE value, so an untouched row reads as the live global. Touching a control records the override — always, even when the new value equals today's global, because that is a pin and the profile must keep it when the global later moves. Nothing is inferred by diffing at save time. Overridden rows say so (accent dot + "Overrides Default settings") and carry the only way back: an explicit Reset. That pair is not garnish. The model deliberately never infers "not overridden" from a value comparison, so without a reset affordance a profile is a one-way door. Tier-G and tier-H rows don't render in profile scope at all — this device's speaker, microphone and channel, its pointer capture and haptics, the HUD corner, the library switch, the gamepad-UI switch, which physical pad is forwarded, and auto-wake, which is about a host and a network rather than about Game vs Work. Sections that would be left empty (Session off macOS, Library) collapse instead of rendering a bare group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
8.2 KiB
Swift
185 lines
8.2 KiB
Swift
// 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: - Described rows (the 2026-07 revamp's field idiom)
|
|
|
|
/// A control with its explanation attached to the SAME cell: the field, then a tight caption
|
|
/// directly under it. This replaced the per-section footer paragraphs — a description the eye
|
|
/// can't match to its field is one nobody reads. Keep captions to one or two sentences; when
|
|
/// a picker's meaning depends on the selection, pass a DYNAMIC string describing the current
|
|
/// choice.
|
|
/// `field` is the overlay's name for this row (see `SettingsField`). Passing it puts the
|
|
/// override marker + Reset in the caption line while a profile is being edited — with the row
|
|
/// it belongs to, which is the only place the state is legible.
|
|
@ViewBuilder
|
|
func described<Content: View>(
|
|
_ caption: String, field: String? = nil, @ViewBuilder content: () -> Content
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
content()
|
|
Text(caption)
|
|
.font(.geist(13, relativeTo: .footnote))
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true) // wrap, never truncate, in Form cells
|
|
// Cap the caption's line length well short of the cell: a full-width caption runs
|
|
// its text right up to the control column (toggles especially), reading as one
|
|
// colliding block. ~46 chars/line also just measures better.
|
|
.frame(maxWidth: 360, alignment: .leading)
|
|
if let field {
|
|
overrideMarker(field)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
// 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
|
|
|
|
/// tvOS's cluster caption (the touch/desktop forms describe bitrate per-row instead).
|
|
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. Scoped, so
|
|
/// flipping it in a profile records the override there rather than moving the global.
|
|
var automaticBitrate: Binding<Bool> {
|
|
let bitrate = scoped(SettingsFields.bitrateKbps)
|
|
return Binding(
|
|
get: { bitrate.wrappedValue == 0 },
|
|
set: { bitrate.wrappedValue = $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> {
|
|
let bitrate = scoped(SettingsFields.bitrateKbps)
|
|
return Binding(
|
|
get: {
|
|
let v = Double(bitrate.wrappedValue)
|
|
.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)
|
|
bitrate.wrappedValue = Int((raw / mag).rounded() * mag)
|
|
})
|
|
}
|
|
|
|
// MARK: - Statistics
|
|
|
|
static var statisticsDescription: String {
|
|
let base = "Live session stats in a corner overlay — Compact is a one-line pill, "
|
|
+ "Detailed adds the latency stage breakdown."
|
|
#if os(macOS)
|
|
return base + " ⌃⌥⇧S cycles the tiers any time."
|
|
#elseif os(iOS)
|
|
return base + " ⌃⌥⇧S or a three-finger tap cycles the tiers any time."
|
|
#else
|
|
return base
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Controllers
|
|
|
|
/// tvOS's cluster caption (the touch/desktop form describes each row inline instead).
|
|
static let controllersFooter =
|
|
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
|
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
|
+ "Applies from the next session."
|
|
|
|
/// "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()
|
|
// Every forwarded controller is surfaced (not just the primary `active`) with its
|
|
// wire pad index as a player number — a pin forwards only one, Automatic forwards all.
|
|
if let pad = gamepads.padIndex(for: controller) {
|
|
Text("Player \(pad + 1)")
|
|
.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
|
|
setResolution(
|
|
width: Int(screen.frame.width * scale), height: Int(screen.frame.height * scale))
|
|
scoped(SettingsFields.refreshHz).wrappedValue = screen.maximumFramesPerSecond
|
|
#else
|
|
// nativeBounds is portrait-oriented pixels — streams are landscape.
|
|
let bounds = UIScreen.main.nativeBounds
|
|
setResolution(
|
|
width: Int(max(bounds.width, bounds.height)),
|
|
height: Int(min(bounds.width, bounds.height)))
|
|
scoped(SettingsFields.refreshHz).wrappedValue = 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
|
|
}
|
|
}
|