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>
358 lines
16 KiB
Swift
358 lines
16 KiB
Swift
// The gamepad-driven settings screen (iOS/iPadOS/macOS): the couch-relevant subset of SettingsView,
|
||
// restyled as a console settings page and fully navigable with a controller — up/down moves the
|
||
// focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the
|
||
// gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom
|
||
// resolutions, the log bitrate slider, debug tools), and both write the same DefaultsKey storage,
|
||
// so values round-trip freely between the two.
|
||
//
|
||
// Rows are rebuilt from live @AppStorage on every render; the focus list dispatches adjust/
|
||
// activate back here BY ROW ID (see `adjust`/`activate`), so a stored input callback can never act
|
||
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
|
||
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
#if os(iOS) || os(macOS)
|
||
import GameController
|
||
|
||
struct GamepadSettingsView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||
@AppStorage(DefaultsKey.enable444) private var enable444 = true
|
||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||
@ObservedObject private var gamepads = GamepadManager.shared
|
||
|
||
#if os(iOS)
|
||
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||
|
||
private var compact: Bool { vSizeClass == .compact }
|
||
#else
|
||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||
#endif
|
||
@State private var focusID: String?
|
||
|
||
var body: some View {
|
||
GamepadMenuList(
|
||
items: rows,
|
||
focusID: $focusID,
|
||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||
onActivate: { activate(id: $0.id) },
|
||
onBack: { dismiss() }
|
||
) { row, focused in
|
||
rowView(row, focused: focused)
|
||
.frame(maxWidth: 620)
|
||
.padding(.horizontal, 24)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.safeAreaInset(edge: .top, spacing: 0) {
|
||
Text("Settings")
|
||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
||
.foregroundStyle(.white)
|
||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||
.padding(.bottom, compact ? 4 : 8)
|
||
.frame(maxWidth: .infinity)
|
||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||
.background { GamepadTrayScrim(edge: .top) }
|
||
}
|
||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(focusedDetail)
|
||
.font(.geist(13, relativeTo: .caption))
|
||
.foregroundStyle(.white.opacity(0.55))
|
||
.lineLimit(2, reservesSpace: true)
|
||
.animation(.smooth(duration: 0.2), value: focusID)
|
||
GamepadHintBar(hints: [
|
||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||
])
|
||
}
|
||
.padding(.leading, 22)
|
||
.padding(.trailing, 22)
|
||
.padding(.vertical, compact ? 6 : 10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background { GamepadTrayScrim(edge: .bottom) }
|
||
}
|
||
.background { GamepadScreenBackground() }
|
||
.onAppear {
|
||
gamepads.refresh()
|
||
gamepads.startDiscovery()
|
||
}
|
||
.onDisappear { gamepads.stopDiscovery() }
|
||
}
|
||
|
||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||
/// rides the cancel action.
|
||
private var closeButton: some View {
|
||
Button { dismiss() } label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 34, height: 34)
|
||
.glassBackground(Circle(), interactive: true)
|
||
.contentShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.keyboardShortcut(.cancelAction)
|
||
.accessibilityLabel("Close settings")
|
||
}
|
||
|
||
// MARK: - Row rendering
|
||
|
||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
if let header = row.header {
|
||
Text(header)
|
||
.font(.geist(12, .semibold, relativeTo: .caption))
|
||
.tracking(1.4)
|
||
.foregroundStyle(.white.opacity(0.45))
|
||
.padding(.leading, 16)
|
||
.padding(.top, 14)
|
||
}
|
||
HStack(spacing: 14) {
|
||
Image(systemName: row.icon)
|
||
.font(.system(size: 17))
|
||
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
|
||
.frame(width: 28)
|
||
Text(row.label)
|
||
.font(.geist(16, .semibold, relativeTo: .body))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(1)
|
||
Spacer(minLength: 12)
|
||
HStack(spacing: 9) {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||
Text(row.value)
|
||
.font(.geist(15, .medium, relativeTo: .callout))
|
||
.foregroundStyle(focused ? .white : .white.opacity(0.6))
|
||
.lineLimit(1)
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 13)
|
||
.background {
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.fill(.white.opacity(focused ? 0.1 : 0))
|
||
}
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.strokeBorder(.white.opacity(focused ? 0.22 : 0), lineWidth: 1)
|
||
}
|
||
.scaleEffect(focused ? 1.0 : 0.98)
|
||
.animation(.smooth(duration: 0.18), value: focused)
|
||
}
|
||
}
|
||
|
||
private var focusedDetail: String {
|
||
rows.first { $0.id == focusID }?.detail ?? " "
|
||
}
|
||
|
||
// MARK: - Row model
|
||
|
||
private struct Row: Identifiable {
|
||
let id: String
|
||
/// Section header drawn above this row (the first row of each group carries it).
|
||
var header: String?
|
||
let icon: String
|
||
let label: String
|
||
let value: String
|
||
/// One-line explanation shown near the hint bar while this row is focused.
|
||
let detail: String
|
||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||
let adjust: (Int) -> Bool
|
||
/// A — cycle forward (wrapping) / flip.
|
||
let activate: () -> Void
|
||
}
|
||
|
||
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
||
/// (never on state captured at wire time).
|
||
private func adjust(id: String, by delta: Int) -> Bool {
|
||
rows.first { $0.id == id }?.adjust(delta) ?? false
|
||
}
|
||
|
||
private func activate(id: String) {
|
||
rows.first { $0.id == id }?.activate()
|
||
}
|
||
|
||
private var rows: [Row] {
|
||
let resolution = resolutionOptions
|
||
let refresh = SettingsOptions.refreshRates(including: hz)
|
||
.map { (label: "\($0) Hz", tag: $0) }
|
||
let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps)
|
||
let controllers = SettingsOptions.controllerOptions(gamepads)
|
||
return [
|
||
choiceRow(
|
||
id: "resolution", header: "Stream", icon: "aspectratio",
|
||
label: "Resolution",
|
||
detail: "The host creates a virtual display at exactly this size — no scaling.",
|
||
options: resolution, current: "\(width)x\(height)"
|
||
) { tag in
|
||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||
guard parts.count == 2 else { return }
|
||
width = parts[0]
|
||
height = parts[1]
|
||
},
|
||
choiceRow(
|
||
id: "refresh", icon: "gauge.with.needle", label: "Refresh rate",
|
||
detail: "Rates this display can actually show.",
|
||
options: refresh, current: hz
|
||
) { hz = $0 },
|
||
choiceRow(
|
||
id: "bitrate", icon: "speedometer", label: "Bitrate",
|
||
detail: "Automatic uses the host's default (20 Mbps). "
|
||
+ "Run a speed test from the touch UI for an informed value.",
|
||
options: bitrate, current: bitrateKbps
|
||
) { bitrateKbps = $0 },
|
||
choiceRow(
|
||
id: "compositor", icon: "macwindow", label: "Compositor",
|
||
detail: "Which compositor drives the virtual output — honored only if "
|
||
+ "available on the host.",
|
||
options: SettingsOptions.compositors, current: compositor
|
||
) { compositor = $0 },
|
||
|
||
choiceRow(
|
||
id: "codec", header: "Video", icon: "film", label: "Video codec",
|
||
detail: "A preference — the host falls back if it can't encode this one "
|
||
+ "(10-bit and 4:4:4 are HEVC-only).",
|
||
options: SettingsOptions.codecs, current: codec
|
||
) { codec = $0 },
|
||
toggleRow(
|
||
id: "hdr", icon: "sun.max", label: "10-bit HDR",
|
||
detail: "HDR10 — engages when the host sends HDR content and this display "
|
||
+ "supports it.",
|
||
value: $hdrEnabled),
|
||
toggleRow(
|
||
id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)",
|
||
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
||
+ "hardware decode.",
|
||
value: $enable444),
|
||
|
||
choiceRow(
|
||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||
detail: "The speaker layout requested from the host.",
|
||
options: SettingsOptions.audioChannels, current: audioChannels
|
||
) { audioChannels = $0 },
|
||
toggleRow(
|
||
id: "mic", icon: "mic", label: "Microphone",
|
||
detail: "Send this device's microphone to the host's virtual mic.",
|
||
value: $micEnabled),
|
||
|
||
choiceRow(
|
||
id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller",
|
||
detail: "Which pad is forwarded to the host, as player 1.",
|
||
options: controllers, current: gamepads.preferredID
|
||
) { gamepads.preferredID = $0 },
|
||
choiceRow(
|
||
id: "padType", icon: "dpad", label: "Controller type",
|
||
detail: "The virtual pad the host creates — Automatic matches this controller.",
|
||
options: SettingsOptions.padTypes, current: gamepadType
|
||
) { gamepadType = $0 },
|
||
|
||
toggleRow(
|
||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||
detail: "Resolution, frame rate, throughput and latency while streaming.",
|
||
value: $hudEnabled),
|
||
choiceRow(
|
||
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
||
detail: "Which corner the statistics overlay sits in.",
|
||
options: SettingsOptions.hudPlacements, current: hudPlacement
|
||
) { hudPlacement = $0 },
|
||
toggleRow(
|
||
id: "library", icon: "square.grid.2x2", label: "Game library",
|
||
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) "
|
||
+ "(experimental).",
|
||
value: $libraryEnabled),
|
||
toggleRow(
|
||
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||
value: $gamepadUIEnabled),
|
||
]
|
||
}
|
||
|
||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||
/// (set via the touch settings), so cycling starts from it instead of jumping.
|
||
private var resolutionOptions: [(label: String, tag: String)] {
|
||
var options = SettingsOptions.resolutionModes()
|
||
.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") }
|
||
let current = "\(width)x\(height)"
|
||
if !options.contains(where: { $0.tag == current }) {
|
||
options.insert((label: "Custom · \(width) × \(height)", tag: current), at: 0)
|
||
}
|
||
return options
|
||
}
|
||
|
||
/// The active controller's user-facing name for a button (for detail strings).
|
||
private func buttonName(
|
||
_ button: KeyPath<GCExtendedGamepad, GCControllerButtonInput>, _ fallback: String
|
||
) -> String {
|
||
gamepads.active?.controller.extendedGamepad?[keyPath: button].localizedName ?? fallback
|
||
}
|
||
|
||
// MARK: - Row builders
|
||
|
||
private func choiceRow<T: Equatable>(
|
||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||
options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void
|
||
) -> Row {
|
||
let index = options.firstIndex { $0.tag == current }
|
||
return Row(
|
||
id: id, header: header, icon: icon, label: label,
|
||
value: index.map { options[$0].label } ?? "—",
|
||
detail: detail,
|
||
adjust: { delta in
|
||
// Unknown current value: snap to the first option on any step.
|
||
guard let index else {
|
||
guard let first = options.first else { return false }
|
||
write(first.tag)
|
||
return true
|
||
}
|
||
let target = index + delta
|
||
guard target >= 0, target < options.count else { return false }
|
||
write(options[target].tag)
|
||
return true
|
||
},
|
||
activate: {
|
||
guard let index else { return write(options.first?.tag ?? current) }
|
||
write(options[(index + 1) % options.count].tag)
|
||
})
|
||
}
|
||
|
||
private func toggleRow(
|
||
id: String, header: String? = nil, icon: String, label: String, detail: String,
|
||
value: Binding<Bool>
|
||
) -> Row {
|
||
Row(
|
||
id: id, header: header, icon: icon, label: label,
|
||
value: value.wrappedValue ? "On" : "Off",
|
||
detail: detail,
|
||
adjust: { delta in
|
||
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
|
||
let target = delta > 0
|
||
guard value.wrappedValue != target else { return false }
|
||
value.wrappedValue = target
|
||
return true
|
||
},
|
||
activate: { value.wrappedValue.toggle() })
|
||
}
|
||
}
|
||
|
||
#endif
|