133e25849d
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit: Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split along the same seams. The gamepad mode is couch-complete, and now on macOS too (the living-room Mac case), not just iOS/iPadOS: - GamepadSettingsView: a console-style, fully controller-navigable settings screen (X from the launcher) — up/down moves focus, left/right steps values (clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a one-line description. Backed by GamepadMenuList, the vertical sibling of GamepadCarousel, and SettingsOptions — the option lists hoisted out of SettingsView statics and shared by the touch, tvOS and gamepad settings. - GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad — field rows open an on-screen controller keyboard (dpad grid, A types, X backspaces, B done); the launcher carousel ends in an Add Host tile, so the dead-end "add one with touch first" empty state is gone. - Launcher polish: contextual hint bar with the pad's real button glyphs, controller name + battery chip, one shared console chrome. - GamepadScreenBackground: an animated aurora (TimelineView-driven drifting blobs in the brand's violet family, breathing radii, slow hue shift, legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a .metal library only bundles reliably in one of the two build systems (SPM vs the xcodeproj's synced folders) these sources compile under. - macOS port: settings/add-host/library present as sized sheets (a macOS sheet takes its content's IDEAL size, and the GeometryReader-driven screens collapsed to nothing), NSScreen-based mode lists, scroll indicators .never (the "always show scroll bars" setting overrides .hidden), tray scrims so scrolled rows dim under the pinned title/hints, extra title clearance, and a PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/ library render-verified live on a real Mac + LAN hosts. - GamepadMenuInput: X button support, and (re)start now snapshots held buttons so a controller handoff press never fires twice (the B that closed the keyboard no longer also cancels the screen underneath). - Cleanups: one "Connection failed" alert in ContentView instead of one per home screen; HostDiscovery.advertises/unsaved shared by both home screens. - host: can_encode_444 stub for the non-Linux/Windows host build (the macOS synthetic-source loopback used by the Swift tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
386 lines
17 KiB
Swift
386 lines
17 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)
|
||
|
||
@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<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
|
||
|
||
@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)
|
||
}
|
||
}
|
||
}
|