4f3cd24036
release / apple (push) Successful in 6m29s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m13s
apple / swift (push) Successful in 1m5s
apple / screenshots (push) Successful in 4m5s
ci / bench (push) Successful in 4m36s
ci / rust (push) Successful in 11m23s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 4m26s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m45s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m37s
android / android (push) Successful in 9m37s
docker / deploy-docs (push) Successful in 19s
The Section's iOS content (resolution wheel + 3-way refresh rows + bitrate rows) as ONE ViewBuilder expression hit "the compiler is unable to type-check this expression in reasonable time" — failing exactly one build slice, the iOS archive, so swift test (macOS) and the tvOS/macOS archives never saw it and the 0.6.0 iOS TestFlight upload soft-failed. Extracted iosResolutionWheel / iosRefreshRows / bitrateRows; no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
417 lines
18 KiB
Swift
417 lines
18 KiB
Swift
// 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)
|
||
|
||
// NOTE: the Section content is deliberately split into the small named builders below — as one
|
||
// inline expression the iOS branch (wheel + 3-way refresh + bitrate rows) blew Swift's
|
||
// type-checker budget ("unable to type-check this expression in reasonable time"), which
|
||
// failed exactly one slice: the iOS archive (macOS/tvOS never compile that branch).
|
||
@ViewBuilder var streamModeSection: some View {
|
||
Section {
|
||
#if os(iOS)
|
||
iosResolutionWheel
|
||
iosRefreshRows
|
||
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)
|
||
bitrateRows
|
||
#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)
|
||
|
||
/// Touch-first: a rotating wheel of common resolutions (this device's own mode first) — 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 (see `iosRefreshRows`).
|
||
@ViewBuilder private var iosResolutionWheel: some View {
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// Custom W×H(+Hz) fields, a segmented refresh picker, or a static single-rate row.
|
||
@ViewBuilder private var iosRefreshRows: some View {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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<String> {
|
||
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<String> {
|
||
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
|
||
|
||
#if !os(tvOS)
|
||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||
@ViewBuilder private var bitrateRows: some View {
|
||
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
|
||
|
||
@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)
|
||
/// Touch-input model (iPhone + iPad) plus the iPad-only pointer-capture toggle: lock the
|
||
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
||
@ViewBuilder var pointerSection: some View {
|
||
let isPad = UIDevice.current.userInterfaceIdiom == .pad
|
||
Section {
|
||
Picker("Touch input", selection: $touchMode) {
|
||
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
||
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
||
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
||
}
|
||
if isPad {
|
||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||
}
|
||
} header: {
|
||
Text("Touch & pointer")
|
||
} footer: {
|
||
Text("Trackpad: your finger nudges the host cursor like a laptop touchpad — tap to "
|
||
+ "click, two-finger tap for a right click, two-finger drag to scroll, "
|
||
+ "tap-then-drag to hold the button, three-finger tap for the stats overlay. "
|
||
+ "Direct pointer: the cursor jumps to your finger. Touch passthrough: real "
|
||
+ "multi-touch reaches the host, for apps that understand touch. Applies from "
|
||
+ "the next touch."
|
||
+ (isPad
|
||
? " Pointer capture locks a hardware mouse/trackpad for relative movement "
|
||
+ "(mouse-look); off keeps the pointer free and sends absolute positions. "
|
||
+ "The lock needs the stream full-screen and frontmost, and falls back "
|
||
+ "automatically (Stage Manager, Slide Over)."
|
||
: ""))
|
||
.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)
|
||
}
|
||
}
|
||
}
|