97c67b2692
Roll the pf-client-core slot pattern to the Apple client (Swift): - GamepadManager tracks all connected GCControllers, assigning each a stable lowest-free wire pad index + concrete type, emitting GamepadArrival on connect and GamepadRemove on disconnect (index freed for reuse on re-plug). - GamepadCapture binds every controller with per-controller Slot state (buttons/axes/fingers/motion), threading the pad index into flags on every event; GamepadWire/InputEvents carry the pad + the two new events. - GamepadFeedback + RumbleRenderer go per-pad (rumbleByPad, slots[pad]), routing rumble/HID back to the correct controller by wire index. - ContentView/Settings surface every forwarded controller. pad 0 => flags 0, so single-controller wire is byte-identical. Cannot build on the Linux dev box (no Swift toolchain / Apple frameworks); wire bytes hand-checked against input.rs and GamepadWireTests extended for multi-pad. CI apple.yml (swift build/test on macOS) is the compile gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
6.9 KiB
Swift
169 lines
6.9 KiB
Swift
// SettingsView's footers and stateful helpers, used by both the section builders
|
|
// (SettingsView+Sections.swift) and the per-platform bodies (SettingsView.swift). The option
|
|
// LISTS live in SettingsOptions — they're shared with the gamepad settings screen too.
|
|
|
|
#if os(macOS)
|
|
import AppKit
|
|
#endif
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
|
|
extension SettingsView {
|
|
// MARK: - Bitrate
|
|
|
|
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
|
/// (a few Mbps … 3 Gbps) — linear would cram everything below 100 Mbps into the
|
|
/// first pixels.
|
|
private static let minSliderKbps = 2_000.0
|
|
private static let maxSliderKbps = 3_000_000.0
|
|
|
|
static let bitrateFooter =
|
|
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
|
|
+ "to its supported range. Run a speed test from a host card's context menu to "
|
|
+ "pick an informed value. Applies from the next session."
|
|
|
|
static let gigabitWarning =
|
|
"Above 1 Gbps — test the network speed first (a host card's context menu → "
|
|
+ "Test Network Speed…). A bitrate beyond what the link sustains causes loss "
|
|
+ "and stutter."
|
|
|
|
/// `bitrateKbps == 0` is Automatic; switching to manual lands on the host default.
|
|
var automaticBitrate: Binding<Bool> {
|
|
Binding(
|
|
get: { bitrateKbps == 0 },
|
|
set: { bitrateKbps = $0 ? 0 : 20_000 })
|
|
}
|
|
|
|
/// Slider position 0...1 ↔ kbps on the log scale, snapped to two significant figures
|
|
/// so the readout shows round numbers instead of 47_322.
|
|
var bitrateSlider: Binding<Double> {
|
|
Binding(
|
|
get: {
|
|
let v = Double(bitrateKbps).clamped(Self.minSliderKbps, Self.maxSliderKbps)
|
|
return log(v / Self.minSliderKbps)
|
|
/ log(Self.maxSliderKbps / Self.minSliderKbps)
|
|
},
|
|
set: { pos in
|
|
let raw = Self.minSliderKbps
|
|
* pow(Self.maxSliderKbps / Self.minSliderKbps, pos)
|
|
let mag = pow(10, floor(log10(raw)) - 1)
|
|
bitrateKbps = Int((raw / mag).rounded() * mag)
|
|
})
|
|
}
|
|
|
|
// MARK: - Statistics
|
|
|
|
static var statisticsFooter: String {
|
|
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
|
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
|
+ "breakdown."
|
|
#if os(macOS)
|
|
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
|
#elseif os(iOS)
|
|
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
|
+ "any time."
|
|
#else
|
|
return base
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Audio
|
|
|
|
static var audioFooter: String {
|
|
#if os(macOS)
|
|
return "Host audio plays through the chosen speaker; your microphone feeds the host's "
|
|
+ "virtual mic. System default follows your Mac's device changes. Applies from the "
|
|
+ "next session."
|
|
#else
|
|
return "Host audio plays locally; your microphone feeds the host's virtual mic. "
|
|
+ "Applies from the next session."
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Controllers
|
|
|
|
static let controllersFooter =
|
|
"One controller is forwarded as player 1 — Automatic picks the most recently "
|
|
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
|
|
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
|
|
+ "Applies from the next session."
|
|
|
|
#if !os(tvOS)
|
|
static let gamepadUIFooter =
|
|
"When a controller connects, the host list and library switch to a controller-"
|
|
+ "friendly layout — larger focus targets and a swipeable cover browser. Turn this "
|
|
+ "off to always use the standard layout."
|
|
#endif
|
|
|
|
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
|
|
var controllerOptions: [(label: String, tag: String)] {
|
|
SettingsOptions.controllerOptions(gamepads)
|
|
}
|
|
|
|
func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill")
|
|
.foregroundStyle(.secondary)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(controller.name)
|
|
HStack(spacing: 8) {
|
|
if !controller.isExtended {
|
|
Text(controller.productCategory)
|
|
}
|
|
if controller.hasAdaptiveTriggers {
|
|
Image(systemName: "r2.button.roundedtop.horizontal")
|
|
}
|
|
if controller.hasLight {
|
|
Image(systemName: "lightbulb.fill")
|
|
}
|
|
if controller.hasMotion {
|
|
Image(systemName: "gyroscope")
|
|
}
|
|
if controller.hasHaptics {
|
|
Image(systemName: "waveform")
|
|
}
|
|
if let level = controller.batteryLevel {
|
|
Text("\(Int(level * 100))%")
|
|
if controller.isCharging {
|
|
Image(systemName: "bolt.fill")
|
|
}
|
|
}
|
|
}
|
|
.font(.geist(11, relativeTo: .caption2))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
// Every forwarded controller is surfaced (not just the primary `active`) with its
|
|
// wire pad index as a player number — a pin forwards only one, Automatic forwards all.
|
|
if let pad = gamepads.padIndex(for: controller) {
|
|
Text("Player \(pad + 1)")
|
|
.font(.geist(11, .semibold, relativeTo: .caption2))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(Capsule().fill(.green.opacity(0.2)))
|
|
.foregroundStyle(.green)
|
|
}
|
|
}
|
|
}
|
|
|
|
func fillFromMainScreen() {
|
|
#if os(macOS)
|
|
guard let screen = NSScreen.main else { return }
|
|
let scale = screen.backingScaleFactor
|
|
width = Int(screen.frame.width * scale)
|
|
height = Int(screen.frame.height * scale)
|
|
hz = screen.maximumFramesPerSecond
|
|
#else
|
|
// nativeBounds is portrait-oriented pixels — streams are landscape.
|
|
let bounds = UIScreen.main.nativeBounds
|
|
width = Int(max(bounds.width, bounds.height))
|
|
height = Int(min(bounds.width, bounds.height))
|
|
hz = UIScreen.main.maximumFramesPerSecond
|
|
#if os(iOS)
|
|
// The native mode is the "This device" wheel row, so leave Custom mode if it was on.
|
|
customMode = false
|
|
#endif
|
|
#endif
|
|
}
|
|
}
|