118752c136
apple / swift (push) Successful in 54s
release / apple (push) Successful in 5m3s
ci / rust (push) Failing after 31s
ci / web (push) Successful in 38s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m32s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m27s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m2s
GameController's CHHapticEngine never reaches the DualSense's motors on macOS — its
adaptive triggers and lightbar work, but rumble stays silent (a documented platform
gap). Drive the motors directly via the DualSense HID output report instead, the way
SDL and the Linux hid-playstation driver do — the same report that already rumbles
the pad on a Linux host. Confirmed live on macOS.
- DualSenseHID (macOS): opens the Sony DualSense via IOHIDManager and writes the USB
(0x02, 48 bytes) and Bluetooth (0x31, 78 bytes + CRC32) output reports through
IOHIDDeviceSetReport. Allowed under the App Sandbox by the existing device.usb +
device.bluetooth entitlements; coexists with GameController (non-seized open).
Flags mirror the kernel driver (COMPATIBLE_VIBRATION | HAPTICS_SELECT +
COMPATIBLE_VIBRATION2); valid_flag1 = 0 so a rumble report leaves the
GameController-managed lightbar / triggers / player LEDs untouched.
- RumbleRenderer routes a DualSense to the HID backend and keeps CoreHaptics for
every other pad, fixing both live sessions and the test panel (shared renderer).
- CoreHaptics path reworked too: bake the target intensity + an explicit sharpness
into the continuous event (the dynamic-parameter scaling is silent on controller
engines) and tear down outside the inout access to fix a latent exclusivity hazard.
Adds a DEBUG-only Settings -> Controllers -> "Test Controller" panel (ControllerTestView
+ ControllerTester) that shows live input and fires rumble / adaptive triggers /
lightbar / player LEDs straight at the pad, with a readout of the active rumble backend
("DualSense HID - USB/Bluetooth"). Used to validate the fix.
Tests: DualSenseHIDTests pins the USB/BT report layout and the BT CRC32 (canonical
0xCBF43926 check vector). Debug + release build clean; gamepad suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
616 lines
24 KiB
Swift
616 lines
24 KiB
Swift
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||
// there is no scaling anywhere in the pipeline.
|
||
//
|
||
// Navigation differs per platform: macOS uses a tabbed preferences window (the sections had
|
||
// outgrown one scrolling pane); iOS uses a single grouped Form; tvOS uses a focus-native
|
||
// pushed-picker layout. The individual sections (`streamModeSection`, `audioSection`, …) are
|
||
// shared across all three so a setting is defined exactly once.
|
||
|
||
#if os(macOS)
|
||
import AppKit
|
||
#endif
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
|
||
@MainActor
|
||
struct SettingsView: 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.presenter) private var presenter = "stage1"
|
||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||
@ObservedObject private var gamepads = GamepadManager.shared
|
||
#if DEBUG && !os(tvOS)
|
||
@State private var showControllerTest = false
|
||
#endif
|
||
#if os(macOS)
|
||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||
@State private var outputDevices: [AudioDevice] = []
|
||
@State private var inputDevices: [AudioDevice] = []
|
||
#endif
|
||
|
||
var body: some View {
|
||
#if os(tvOS)
|
||
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
||
// miserable and the inline field chrome fights the focus system). Modes are
|
||
// preset pickers that push selection lists like the system Settings app.
|
||
tvBody
|
||
#elseif os(macOS)
|
||
macBody
|
||
#else
|
||
iosBody
|
||
#endif
|
||
}
|
||
|
||
// MARK: - macOS: tabbed preferences
|
||
|
||
#if os(macOS)
|
||
private var macBody: some View {
|
||
TabView {
|
||
Form {
|
||
streamModeSection
|
||
compositorSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.tabItem { Label("General", systemImage: "gearshape") }
|
||
|
||
Form {
|
||
presenterSection
|
||
windowSection
|
||
statisticsSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.tabItem { Label("Display", systemImage: "display") }
|
||
|
||
Form {
|
||
audioSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.onAppear {
|
||
outputDevices = AudioDevices.outputs()
|
||
inputDevices = AudioDevices.inputs()
|
||
}
|
||
.tabItem { Label("Audio", systemImage: "speaker.wave.2") }
|
||
|
||
Form {
|
||
controllersSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.onAppear {
|
||
gamepads.refresh()
|
||
gamepads.startDiscovery()
|
||
}
|
||
.onDisappear { gamepads.stopDiscovery() }
|
||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||
|
||
Form {
|
||
experimentalSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||
}
|
||
.frame(width: 480, height: 460)
|
||
}
|
||
#endif
|
||
|
||
// MARK: - iOS: one grouped Form
|
||
|
||
#if os(iOS)
|
||
private var iosBody: some View {
|
||
Form {
|
||
streamModeSection
|
||
audioSection
|
||
compositorSection
|
||
presenterSection
|
||
statisticsSection
|
||
experimentalSection
|
||
controllersSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.onAppear {
|
||
gamepads.refresh()
|
||
gamepads.startDiscovery()
|
||
}
|
||
.onDisappear { gamepads.stopDiscovery() }
|
||
}
|
||
#endif
|
||
|
||
// MARK: - tvOS
|
||
|
||
#if os(tvOS)
|
||
private static let presets: [(label: String, tag: String)] = [
|
||
("720p @ 60", "1280x720x60"),
|
||
("1080p @ 60", "1920x1080x60"),
|
||
("4K @ 60", "3840x2160x60"),
|
||
]
|
||
|
||
private var modeTag: Binding<String> {
|
||
Binding(
|
||
get: { "\(width)x\(height)x\(hz)" },
|
||
set: { tag in
|
||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||
guard parts.count == 3 else { return }
|
||
width = parts[0]
|
||
height = parts[1]
|
||
hz = parts[2]
|
||
})
|
||
}
|
||
|
||
private var hudEnabledTag: Binding<String> {
|
||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||
}
|
||
|
||
private var tvBody: some View {
|
||
let currentTag = "\(width)x\(height)x\(hz)"
|
||
let bounds = UIScreen.main.nativeBounds
|
||
let nativeTag = "\(Int(max(bounds.width, bounds.height)))x"
|
||
+ "\(Int(min(bounds.width, bounds.height)))x\(UIScreen.main.maximumFramesPerSecond)"
|
||
var options = Self.presets
|
||
if !options.contains(where: { $0.tag == nativeTag }) {
|
||
options.insert(("This TV (native)", nativeTag), at: 0)
|
||
}
|
||
if !options.contains(where: { $0.tag == currentTag }) {
|
||
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
||
}
|
||
let compositors: [(label: String, tag: Int)] = [
|
||
("Automatic", 0),
|
||
("KWin (KDE Plasma)", 1),
|
||
("wlroots (Sway / Hyprland)", 2),
|
||
("Mutter (GNOME)", 3),
|
||
("gamescope", 4),
|
||
]
|
||
return ScrollView {
|
||
VStack(spacing: 16) {
|
||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||
TVSelectionRow(
|
||
title: "Bitrate", options: bitrateOptions, selection: $bitrateKbps)
|
||
if bitrateKbps > 1_000_000 {
|
||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.orange)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
TVSelectionRow(
|
||
title: "Compositor", options: compositors, selection: $compositor)
|
||
TVSelectionRow(
|
||
title: "Presenter",
|
||
options: [("Stage 1 (default)", "stage1"), ("Stage 2 (experimental)", "stage2")],
|
||
selection: $presenter)
|
||
Text("The host creates a virtual output at exactly this mode — native "
|
||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||
+ "is honored only if available on the host.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.top, 8)
|
||
TVSelectionRow(
|
||
title: "Statistics overlay",
|
||
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag)
|
||
TVSelectionRow(
|
||
title: "Statistics position", options: Self.placementOptions,
|
||
selection: $hudPlacement)
|
||
ForEach(gamepads.controllers) { controller in
|
||
controllerRow(controller)
|
||
.padding(.horizontal, 24)
|
||
}
|
||
TVSelectionRow(
|
||
title: "Use controller", options: controllerOptions,
|
||
selection: $gamepads.preferredID)
|
||
TVSelectionRow(
|
||
title: "Controller type", options: Self.padTypes, selection: $gamepadType)
|
||
Text(Self.controllersFooter)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.top, 8)
|
||
}
|
||
.frame(maxWidth: 1000)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(60)
|
||
}
|
||
.navigationTitle("Settings")
|
||
.onAppear {
|
||
gamepads.refresh()
|
||
gamepads.startDiscovery()
|
||
}
|
||
.onDisappear { gamepads.stopDiscovery() }
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Sections (shared)
|
||
|
||
@ViewBuilder private var streamModeSection: some View {
|
||
Section {
|
||
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() }
|
||
}
|
||
#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(.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(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var audioSection: some View {
|
||
Section {
|
||
#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(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private var compositorSection: some View {
|
||
Section {
|
||
Picker("Compositor", selection: $compositor) {
|
||
Text("Automatic").tag(0)
|
||
Text("KWin (KDE Plasma)").tag(1)
|
||
Text("wlroots (Sway / Hyprland)").tag(2)
|
||
Text("Mutter (GNOME)").tag(3)
|
||
Text("gamescope").tag(4)
|
||
}
|
||
} 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(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private 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(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
@ViewBuilder private var presenterSection: some View {
|
||
Section {
|
||
Picker("Presenter", selection: $presenter) {
|
||
Text("Stage 1 (default)").tag("stage1")
|
||
Text("Stage 2 (experimental)").tag("stage2")
|
||
}
|
||
} header: {
|
||
Text("Video presenter")
|
||
} footer: {
|
||
Text("Stage 1 feeds compressed video to the system display layer (known-good). "
|
||
+ "Stage 2 decodes explicitly and presents through Metal with a display "
|
||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD "
|
||
+ "and shortens the present tail. Applies from the next session.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private 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(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private 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. "
|
||
+ "The host must expose that API on the LAN with a token "
|
||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private 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(Self.padTypes, id: \.tag) { option in
|
||
Text(option.label).tag(option.tag)
|
||
}
|
||
}
|
||
#if DEBUG && !os(tvOS)
|
||
Button("Test Controller…") { showControllerTest = true }
|
||
.disabled(gamepads.active == nil)
|
||
.sheet(isPresented: $showControllerTest) { ControllerTestView() }
|
||
#endif
|
||
} header: {
|
||
Text("Controllers")
|
||
} footer: {
|
||
Text(Self.controllersFooter)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
|
||
private 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."
|
||
|
||
private 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.
|
||
private 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.
|
||
private 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)
|
||
})
|
||
}
|
||
|
||
#if os(tvOS)
|
||
/// tvOS has no Slider — the focus-native control is the pushed picker (the same
|
||
/// pattern as the stream mode), so the rates are presets here, up to the same 3 Gbps
|
||
/// ceiling, plus a custom entry so a non-preset stored value stays visible.
|
||
private static let bitratePresets: [(label: String, tag: Int)] = [
|
||
("Automatic", 0),
|
||
("10 Mbps", 10_000),
|
||
("20 Mbps", 20_000),
|
||
("40 Mbps", 40_000),
|
||
("80 Mbps", 80_000),
|
||
("150 Mbps", 150_000),
|
||
("300 Mbps", 300_000),
|
||
("500 Mbps", 500_000),
|
||
("1 Gbps", 1_000_000),
|
||
("1.5 Gbps", 1_500_000),
|
||
("2 Gbps", 2_000_000),
|
||
("3 Gbps", 3_000_000),
|
||
]
|
||
|
||
private var bitrateOptions: [(label: String, tag: Int)] {
|
||
var options = Self.bitratePresets
|
||
if !options.contains(where: { $0.tag == bitrateKbps }) {
|
||
options.insert(
|
||
(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps) + " (custom)", bitrateKbps), at: 1)
|
||
}
|
||
return options
|
||
}
|
||
|
||
private static let placementOptions: [(label: String, tag: String)] =
|
||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||
#endif
|
||
|
||
// MARK: - Statistics
|
||
|
||
private static var statisticsFooter: String {
|
||
let base = "The overlay shows resolution, frame rate, throughput and latency while "
|
||
+ "streaming, in the chosen corner."
|
||
#if os(macOS) || os(iOS)
|
||
return base + " Toggle it any time with ⌘⇧S."
|
||
#else
|
||
return base
|
||
#endif
|
||
}
|
||
|
||
// MARK: - Controllers
|
||
|
||
private static let padTypes: [(label: String, tag: Int)] = [
|
||
("Automatic", 0),
|
||
("Xbox 360", 1),
|
||
("Xbox One", 3),
|
||
("DualSense", 2),
|
||
("DualShock 4", 4),
|
||
]
|
||
|
||
private static let controllersFooter =
|
||
"One controller is forwarded to the host, as player 1 — Automatic picks the most "
|
||
+ "recently connected one. The type is the virtual pad the host creates: Automatic "
|
||
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
|
||
+ "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply "
|
||
+ "from the next session. Two identical controllers may swap a manual selection "
|
||
+ "after reconnecting."
|
||
|
||
/// "Use controller" choices: Automatic, every forwardable controller, and — so a stale
|
||
/// pin stays visible instead of leaving the Picker selection tag-less — any pinned id
|
||
/// that is NOT among the selectable (extended) entries, present-but-unusable included.
|
||
private var controllerOptions: [(label: String, tag: String)] {
|
||
let selectable = gamepads.controllers.filter(\.isExtended)
|
||
var options: [(label: String, tag: String)] = [("Automatic", "")]
|
||
options += selectable.map { ($0.name, $0.id) }
|
||
if !gamepads.preferredID.isEmpty,
|
||
!selectable.contains(where: { $0.id == gamepads.preferredID }) {
|
||
options.append(("Unavailable controller", gamepads.preferredID))
|
||
}
|
||
return options
|
||
}
|
||
|
||
private 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(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
if gamepads.active?.id == controller.id {
|
||
Text("In use")
|
||
.font(.caption2.weight(.semibold))
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 3)
|
||
.background(Capsule().fill(.green.opacity(0.2)))
|
||
.foregroundStyle(.green)
|
||
}
|
||
}
|
||
}
|
||
|
||
private 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
|
||
#endif
|
||
}
|
||
}
|
||
|
||
extension Double {
|
||
/// The log-scale slider mapping needs a bounded input (Automatic stores 0).
|
||
fileprivate func clamped(_ lo: Double, _ hi: Double) -> Double {
|
||
Swift.min(Swift.max(self, lo), hi)
|
||
}
|
||
}
|