d707ee4d4e
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
The two touch clients had exactly complementary gaps: iOS forwarded fingers ONLY as raw wire touches (no way to drive the host cursor from the touch screen), Android had the two mouse modes but no passthrough. Both now share one three-way "Touch input" setting: Trackpad (default) / Direct pointer / Touch passthrough. iOS/iPadOS: Input/TouchMouse.swift ports the Android gesture engine 1:1 (same px-based acceleration curve; tap=click, two-finger tap=right-click, two-finger drag=scroll, tap-then-drag=held drag, three-finger tap=stats HUD via the shared hudEnabled default); direct-pointer mode maps through the aspect-fit letterbox; the previous always-on behavior lives on as the passthrough option. The mode latches per gesture (a Settings change never splits one gesture across models), touchesCancelled releases held state without synthesizing a click, and session stop flushes a mid-drag button. Settings picker on iPhone + iPad next to the iPad-only pointer-capture toggle. Deliberate default change: trackpad, not passthrough. Android: new nativeSendTouch JNI shim → wire TouchDown/Move/Up (the host already injects real touch on every backend — libei touchscreen, wlroots, KWin fake-input, SendInput); streamTouchPassthrough forwards every finger with stable ids and lifts still-held contacts on teardown; the trackpadMode Boolean becomes the TouchMode enum (old pref migrated on load, never rewritten) with a Settings dropdown. Verified: macOS swift build + full suite (incl. new TouchMouseTests), iOS Simulator Swift compile, cargo check/fmt/clippy on the native crate, Kotlin app+kit compile + unit tests. On-glass feel of the iOS ballistics and Android passthrough against a touch-aware app still pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
371 lines
16 KiB
Swift
371 lines
16 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, but all three group the same categories (General, Display,
|
||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||
// a hierarchical push list on iPhone (the system Settings idiom on each); 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 — they
|
||
// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
|
||
|
||
#if os(macOS)
|
||
import AppKit
|
||
#endif
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
|
||
@MainActor
|
||
struct SettingsView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||
@AppStorage(DefaultsKey.presenter) var presenter = "stage2"
|
||
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
||
@AppStorage(DefaultsKey.enable444) var enable444 = true
|
||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
|
||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
||
@AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true
|
||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||
@ObservedObject var gamepads = GamepadManager.shared
|
||
#if !os(tvOS)
|
||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||
#endif
|
||
#if DEBUG && !os(tvOS)
|
||
@State var showControllerTest = false
|
||
#endif
|
||
#if os(iOS)
|
||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||
// General on iPad (a two-column layout should never open with an empty detail).
|
||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||
@State private var settingsSelection: SettingsCategory?
|
||
// Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen
|
||
// — not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts
|
||
// .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible.
|
||
@State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
|
||
// Sticky once the wheel lands on "Custom…", so editing a width/height that briefly equals a
|
||
// preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even
|
||
// when this is false (see `isCustomResolution`), so it survives relaunches without persisting.
|
||
@State var customMode = false
|
||
#endif
|
||
#if os(macOS)
|
||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||
@State var outputDevices: [AudioDevice] = []
|
||
@State var inputDevices: [AudioDevice] = []
|
||
#endif
|
||
|
||
#if os(iOS)
|
||
/// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on
|
||
/// General via `onAppear`). The screenshot harness passes an explicit category so the captured
|
||
/// shot opens on a real settings page (a populated detail) rather than the bare category list.
|
||
init(initialCategory: SettingsCategory? = nil) {
|
||
_settingsSelection = State(initialValue: initialCategory)
|
||
}
|
||
#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
|
||
hdrSection
|
||
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") }
|
||
|
||
AcknowledgementsView()
|
||
.tabItem { Label("About", systemImage: "info.circle") }
|
||
}
|
||
.frame(width: 480, height: 460)
|
||
}
|
||
#endif
|
||
|
||
// MARK: - iOS / iPadOS: adaptive split view
|
||
|
||
#if os(iOS)
|
||
private var iosBody: some View {
|
||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||
List(selection: $settingsSelection) {
|
||
ForEach(SettingsCategory.allCases) { category in
|
||
// On iPhone the split view collapses to a push list, but a selection List
|
||
// draws no disclosure indicator of its own — add one in compact width for the
|
||
// expected drill-in affordance. On iPad the selected row highlights instead, so
|
||
// the chevron is omitted there.
|
||
HStack {
|
||
Label(category.title, systemImage: category.symbol)
|
||
if horizontalSizeClass == .compact {
|
||
Spacer()
|
||
Image(systemName: "chevron.forward")
|
||
.font(.footnote.weight(.semibold))
|
||
.foregroundStyle(.tertiary)
|
||
// Purely a drill-in affordance — the row's button trait already
|
||
// conveys "opens"; keep it out of the VoiceOver announcement.
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
.tag(category)
|
||
}
|
||
}
|
||
.navigationTitle("Settings")
|
||
.toolbar {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("Done") { dismiss() }
|
||
}
|
||
}
|
||
} detail: {
|
||
// NavigationSplitView hosts the detail in its own navigation context (its title bar),
|
||
// so no inner NavigationStack — that would double the bar on iPad. On iPhone the split
|
||
// view collapses to one stack and pushes this when a row is tapped. `?? .general` only
|
||
// backs the brief pre-selection window; the list never auto-pushes on a nil selection.
|
||
settingsDetail(settingsSelection ?? .general)
|
||
// Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the
|
||
// iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the
|
||
// sidebar is showing, its Done is the only one — so this stays hidden to avoid two.
|
||
.toolbar {
|
||
if horizontalSizeClass == .compact || columnVisibility == .detailOnly {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("Done") { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
if horizontalSizeClass == .regular, settingsSelection == nil {
|
||
settingsSelection = .general
|
||
}
|
||
gamepads.refresh()
|
||
gamepads.startDiscovery()
|
||
}
|
||
// A regular→regular launch sets the default above; this catches a compact→regular change
|
||
// (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in.
|
||
.onChange(of: horizontalSizeClass) { _, newValue in
|
||
if newValue == .regular, settingsSelection == nil {
|
||
settingsSelection = .general
|
||
}
|
||
}
|
||
.onDisappear { gamepads.stopDiscovery() }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func settingsDetail(_ category: SettingsCategory) -> some View {
|
||
switch category {
|
||
case .general:
|
||
Form {
|
||
streamModeSection
|
||
pointerSection
|
||
compositorSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.navigationTitle("General")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .display:
|
||
Form {
|
||
presenterSection
|
||
hdrSection
|
||
statisticsSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Display")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .audio:
|
||
Form { audioSection }
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Audio")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .controllers:
|
||
Form { controllersSection }
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Controllers")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .advanced:
|
||
Form { experimentalSection }
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Advanced")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .about:
|
||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||
// the large title from the "Settings" sidebar root).
|
||
AcknowledgementsView()
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
}
|
||
#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 hdrEnabledTag: Binding<String> {
|
||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $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)
|
||
}
|
||
return ScrollView {
|
||
VStack(spacing: 16) {
|
||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||
TVSelectionRow(
|
||
title: "Bitrate",
|
||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||
selection: $bitrateKbps)
|
||
TVSelectionRow(
|
||
title: "Audio channels",
|
||
options: SettingsOptions.audioChannels,
|
||
selection: $audioChannels)
|
||
if bitrateKbps > 1_000_000 {
|
||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||
.font(.geist(12, relativeTo: .caption))
|
||
.foregroundStyle(.orange)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
TVSelectionRow(
|
||
title: "Compositor", options: SettingsOptions.compositors,
|
||
selection: $compositor)
|
||
#if DEBUG
|
||
TVSelectionRow(
|
||
title: "Presenter (debug)",
|
||
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||
selection: $presenter)
|
||
#endif
|
||
TVSelectionRow(
|
||
title: "10-bit HDR",
|
||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||
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(.geist(12, relativeTo: .caption))
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.top, 8)
|
||
TVSelectionRow(
|
||
title: "Statistics overlay",
|
||
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag)
|
||
TVSelectionRow(
|
||
title: "Statistics position", options: SettingsOptions.hudPlacements,
|
||
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: SettingsOptions.padTypes,
|
||
selection: $gamepadType)
|
||
Text(Self.controllersFooter)
|
||
.font(.geist(12, relativeTo: .caption))
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.top, 8)
|
||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||
.padding(.top, 8)
|
||
}
|
||
.frame(maxWidth: 1000)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(60)
|
||
}
|
||
.navigationTitle("Settings")
|
||
.onAppear {
|
||
gamepads.refresh()
|
||
gamepads.startDiscovery()
|
||
}
|
||
.onDisappear { gamepads.stopDiscovery() }
|
||
}
|
||
#endif
|
||
}
|