feat(apple): gamepad UI v2 — controller settings + add host, aurora, macOS

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>
This commit is contained in:
2026-07-02 11:05:10 +02:00
parent e925d00194
commit 133e25849d
84 changed files with 4231 additions and 2698 deletions
@@ -0,0 +1,385 @@
// 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)
}
}
}