// 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 { 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 { 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) } } }