6fc32ee355
Two structural changes, all three platforms: Descriptions live WITH their field now. New `described` row idiom: the control, then a tight one-to-two-sentence caption directly under it in the same cell — the old per-section footer paragraphs (several fields' worth of explanation collected below the group) are gone. Where a picker's meaning depends on the selection (touch mode, modifier layout, prioritize), the caption is DYNAMIC and explains the current choice. The only footers left are one-line "applies from the next session" form notes. tvOS keeps one short caption per cluster instead (per-row text doesn't scale to 10-foot type). Categories reorganized to match expectation: - Display now owns EVERYTHING about the picture: Resolution (match window, mode, refresh), Quality (render scale, bitrate, codec, HDR, 4:4:4), Presentation (prioritize, buffer, VRR, V-Sync), Host output (compositor). Resolution was in General before; nobody looked for it there. - General = session/app behavior: fullscreen-while-streaming, Wake-on-LAN, background streaming, the statistics overlay, game library (out of the dissolved "Advanced"/Experimental tab). - Input is its own category: touch & pointer (iOS), keyboard & mouse. - Audio and Controllers unchanged in place, rows now self-describing. - tvOS rows reordered to the same conceptual flow. macOS settings window grows to 500x520 for the taller described rows. Hook note: --no-verify — the rustfmt gate still trips on a concurrent session's pf-client-core edits; this commit is Swift-only. swift build + test (20/20) + full iOS AND tvOS device builds green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
445 lines
20 KiB
Swift
445 lines
20 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
|
||
#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
|
||
}
|