feat(client/apple): the settings screen edits profiles, and says which rows it changed
One settings surface, two layers. A scope switcher at the top of the Mac window and the iOS sidebar swaps the whole screen between Default settings and one profile's overrides; the section builders stay the single definition of every row and just change which layer their binding reads and writes (design §5.1). A parallel profile editor would have drifted from this one field by field, and the revamp's captions and curation would have had to be written twice. `SettingsFields` is where a row's three faces meet — the UserDefaults key its global lives under, the overlay slot an override lives in, and the name a reset carries. Keeping them in one place is what stops a row from writing an override the reset button can't find. Every row shows the EFFECTIVE value, so an untouched row reads as the live global. Touching a control records the override — always, even when the new value equals today's global, because that is a pin and the profile must keep it when the global later moves. Nothing is inferred by diffing at save time. Overridden rows say so (accent dot + "Overrides Default settings") and carry the only way back: an explicit Reset. That pair is not garnish. The model deliberately never infers "not overridden" from a value comparison, so without a reset affordance a profile is a one-way door. Tier-G and tier-H rows don't render in profile scope at all — this device's speaker, microphone and channel, its pointer capture and haptics, the HUD corner, the library switch, the gamepad-UI switch, which physical pad is forwarded, and auto-wake, which is about a host and a network rather than about Game vs Work. Sections that would be left empty (Session off macOS, Library) collapse instead of rendering a bare group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ struct ContentView: View {
|
||||
@StateObject private var store = HostStore()
|
||||
/// The settings-profile catalog (design/client-settings-profiles.md §4.2) — read at every
|
||||
/// connect to resolve the session's `EffectiveSettings`, and edited by the settings surface.
|
||||
@StateObject private var profiles = ProfileStore()
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
// The dev auto-connect hook writes these three, so they stay observed here; every OTHER
|
||||
// stream setting reaches a session through `EffectiveSettings`, resolved once per connect.
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
// The settings surface edits SETTINGS PROFILES too (design/client-settings-profiles.md §5.1): a
|
||||
// scope switcher swaps the whole screen between the global defaults and one profile's overrides.
|
||||
//
|
||||
// It is deliberately not a second editor. A parallel profile editor would drift from this one
|
||||
// field by field, and the caption/curation work of the 2026-07 revamp would have to be done twice.
|
||||
// So the section builders stay the single definition of every row and simply change which LAYER
|
||||
// their binding reads and writes.
|
||||
//
|
||||
// Three rules make it a profile editor rather than a settings copier:
|
||||
// • every row shows the EFFECTIVE value — the inherited global until this profile overrides it;
|
||||
// • touching a control records the override, always, even when the new value happens to equal
|
||||
// today's global (that is a PIN: it keeps its value when the global later moves). Overrides
|
||||
// are never inferred by diffing at save time;
|
||||
// • the only way back to inheriting is an explicit per-row reset — which is why the marker and
|
||||
// its Reset action below are not optional garnish: without them a profile is a one-way door.
|
||||
//
|
||||
// Tier-G/H rows (this device's endpoints and hardware, properties of a host) simply don't render
|
||||
// in profile scope — see §3's curation and `isProfileable` at each call site.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Which layer the settings surface is editing.
|
||||
enum SettingsScope: Equatable, Hashable {
|
||||
/// The global defaults every profile inherits from — the only scope before this feature.
|
||||
case defaults
|
||||
/// One profile's overrides, by id.
|
||||
case profile(String)
|
||||
|
||||
var profileID: String? {
|
||||
if case .profile(let id) = self { return id }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// One profileable setting, in the one place that knows all three of its faces: the
|
||||
/// `UserDefaults` key its global lives under, the overlay slot an override lives in, and the
|
||||
/// serialized name a per-row reset carries. Keeping them together is what stops a row from
|
||||
/// writing an override the reset button can't find.
|
||||
struct SettingsField<Value> {
|
||||
/// The overlay's serialized field name — also the reset key. `resolution` is the one alias,
|
||||
/// covering the width/height/match-window tri-state a single control drives.
|
||||
let name: String
|
||||
let key: String
|
||||
let overlay: WritableKeyPath<SettingsOverlay, Value?>
|
||||
let effective: KeyPath<EffectiveSettings, Value>
|
||||
}
|
||||
|
||||
/// The catalog of profileable rows. A plain namespace rather than statics on the generic
|
||||
/// `SettingsField` itself: members of a generic type can't have their parameter inferred from the
|
||||
/// member alone, so every call site would need to spell the type out anyway.
|
||||
enum SettingsFields {
|
||||
static var refreshHz: SettingsField<Int> {
|
||||
.init(name: "refresh_hz", key: DefaultsKey.streamHz,
|
||||
overlay: \.refreshHz, effective: \.refreshHz)
|
||||
}
|
||||
static var matchWindow: SettingsField<Bool> {
|
||||
// Part of the resolution tri-state, so its RESET name is the shared alias.
|
||||
.init(name: OverlayField.resolution, key: DefaultsKey.matchWindow,
|
||||
overlay: \.matchWindow, effective: \.matchWindow)
|
||||
}
|
||||
static var width: SettingsField<Int> {
|
||||
.init(name: OverlayField.resolution, key: DefaultsKey.streamWidth,
|
||||
overlay: \.width, effective: \.width)
|
||||
}
|
||||
static var height: SettingsField<Int> {
|
||||
.init(name: OverlayField.resolution, key: DefaultsKey.streamHeight,
|
||||
overlay: \.height, effective: \.height)
|
||||
}
|
||||
static var renderScale: SettingsField<Double> {
|
||||
.init(name: "render_scale", key: DefaultsKey.renderScale,
|
||||
overlay: \.renderScale, effective: \.renderScale)
|
||||
}
|
||||
static var bitrateKbps: SettingsField<Int> {
|
||||
.init(name: "bitrate_kbps", key: DefaultsKey.bitrateKbps,
|
||||
overlay: \.bitrateKbps, effective: \.bitrateKbps)
|
||||
}
|
||||
static var codec: SettingsField<String> {
|
||||
.init(name: "codec", key: DefaultsKey.codec, overlay: \.codec, effective: \.codec)
|
||||
}
|
||||
static var hdrEnabled: SettingsField<Bool> {
|
||||
.init(name: "hdr_enabled", key: DefaultsKey.hdrEnabled,
|
||||
overlay: \.hdrEnabled, effective: \.hdrEnabled)
|
||||
}
|
||||
static var enable444: SettingsField<Bool> {
|
||||
.init(name: "enable_444", key: DefaultsKey.enable444,
|
||||
overlay: \.enable444, effective: \.enable444)
|
||||
}
|
||||
static var compositor: SettingsField<Int> {
|
||||
.init(name: "compositor", key: DefaultsKey.compositor,
|
||||
overlay: \.compositor, effective: \.compositor)
|
||||
}
|
||||
static var audioChannels: SettingsField<Int> {
|
||||
.init(name: "audio_channels", key: DefaultsKey.audioChannels,
|
||||
overlay: \.audioChannels, effective: \.audioChannels)
|
||||
}
|
||||
static var micEnabled: SettingsField<Bool> {
|
||||
.init(name: "mic_enabled", key: DefaultsKey.micEnabled,
|
||||
overlay: \.micEnabled, effective: \.micEnabled)
|
||||
}
|
||||
static var touchMode: SettingsField<String> {
|
||||
.init(name: "touch_mode", key: DefaultsKey.touchMode,
|
||||
overlay: \.touchMode, effective: \.touchMode)
|
||||
}
|
||||
static var mouseMode: SettingsField<String> {
|
||||
.init(name: "mouse_mode", key: DefaultsKey.mouseMode,
|
||||
overlay: \.mouseMode, effective: \.mouseMode)
|
||||
}
|
||||
static var invertScroll: SettingsField<Bool> {
|
||||
.init(name: "invert_scroll", key: DefaultsKey.invertScroll,
|
||||
overlay: \.invertScroll, effective: \.invertScroll)
|
||||
}
|
||||
static var modifierLayout: SettingsField<String> {
|
||||
.init(name: "modifier_layout", key: DefaultsKey.modifierLayout,
|
||||
overlay: \.modifierLayout, effective: \.modifierLayout)
|
||||
}
|
||||
static var gamepadType: SettingsField<Int> {
|
||||
.init(name: "gamepad", key: DefaultsKey.gamepadType,
|
||||
overlay: \.gamepadType, effective: \.gamepadType)
|
||||
}
|
||||
static var statsVerbosity: SettingsField<String> {
|
||||
.init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity,
|
||||
overlay: \.statsVerbosity, effective: \.statsVerbosity)
|
||||
}
|
||||
static var fullscreenWhileStreaming: SettingsField<Bool> {
|
||||
.init(name: "fullscreen_on_stream", key: DefaultsKey.fullscreenWhileStreaming,
|
||||
overlay: \.fullscreenWhileStreaming, effective: \.fullscreenWhileStreaming)
|
||||
}
|
||||
static var presentPriority: SettingsField<String> {
|
||||
.init(name: "present_priority", key: DefaultsKey.presentPriority,
|
||||
overlay: \.presentPriority, effective: \.presentPriority)
|
||||
}
|
||||
static var smoothBuffer: SettingsField<Int> {
|
||||
.init(name: "smooth_buffer", key: DefaultsKey.smoothBuffer,
|
||||
overlay: \.smoothBuffer, effective: \.smoothBuffer)
|
||||
}
|
||||
static var vsync: SettingsField<Bool> {
|
||||
.init(name: "vsync", key: DefaultsKey.vsync, overlay: \.vsync, effective: \.vsync)
|
||||
}
|
||||
static var allowVRR: SettingsField<Bool> {
|
||||
.init(name: "allow_vrr", key: DefaultsKey.allowVRR,
|
||||
overlay: \.allowVRR, effective: \.allowVRR)
|
||||
}
|
||||
static var windowedSafePresent: SettingsField<Bool> {
|
||||
.init(name: "windowed_safe_present", key: DefaultsKey.windowedSafePresent,
|
||||
overlay: \.windowedSafePresent, effective: \.windowedSafePresent)
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsView {
|
||||
// MARK: - The layer being edited
|
||||
|
||||
var activeProfile: StreamProfile? {
|
||||
scope.profileID.flatMap { profiles.profile(id: $0) }
|
||||
}
|
||||
|
||||
/// True while a profile is being edited — the gate every tier-G/H row is hidden behind.
|
||||
var inProfileScope: Bool { activeProfile != nil }
|
||||
|
||||
/// What every row displays: the globals as this view's `@AppStorage` sees them (so SwiftUI
|
||||
/// tracks each of them), with the edited profile's overrides on top. A row the profile doesn't
|
||||
/// override therefore reads as the LIVE global, which is what inherit-by-default has to look
|
||||
/// like.
|
||||
var effective: EffectiveSettings {
|
||||
var base = EffectiveSettings()
|
||||
base.width = width
|
||||
base.height = height
|
||||
base.refreshHz = hz
|
||||
base.matchWindow = matchWindow
|
||||
base.bitrateKbps = bitrateKbps
|
||||
base.renderScale = renderScale
|
||||
base.codec = codec
|
||||
base.hdrEnabled = hdrEnabled
|
||||
base.enable444 = enable444
|
||||
base.compositor = compositor
|
||||
base.audioChannels = audioChannels
|
||||
base.micEnabled = micEnabled
|
||||
base.gamepadType = gamepadType
|
||||
base.statsVerbosity = statsVerbosityRaw
|
||||
base.fullscreenWhileStreaming = fullscreenWhileStreaming
|
||||
base.presentPriority = presentPriority
|
||||
base.smoothBuffer = smoothBuffer
|
||||
#if !os(tvOS)
|
||||
base.invertScroll = invertScroll
|
||||
base.modifierLayout = modifierLayout
|
||||
base.allowVRR = allowVRR
|
||||
#endif
|
||||
#if os(macOS)
|
||||
base.mouseMode = mouseMode
|
||||
base.vsync = vsync
|
||||
base.windowedSafePresent = windowedSafePresent
|
||||
#endif
|
||||
#if os(iOS)
|
||||
base.touchMode = touchMode
|
||||
#endif
|
||||
guard let profile = activeProfile else { return base }
|
||||
return base.applying(profile.overrides)
|
||||
}
|
||||
|
||||
// MARK: - Scoped bindings
|
||||
|
||||
/// A control's binding for whichever layer is being edited: `UserDefaults` in defaults scope,
|
||||
/// the profile's overlay in profile scope.
|
||||
///
|
||||
/// The setter always WRITES on touch. It never compares the new value against the global to
|
||||
/// decide whether to record an override — that comparison is exactly the "copy the settings"
|
||||
/// behaviour the design rejects, and it would silently drop a deliberate pin.
|
||||
func scoped<Value>(_ field: SettingsField<Value>) -> Binding<Value> {
|
||||
Binding(
|
||||
get: { effective[keyPath: field.effective] },
|
||||
set: { newValue in
|
||||
guard let id = scope.profileID else {
|
||||
// @AppStorage observes this key, so the write re-renders the whole surface.
|
||||
UserDefaults.standard.set(newValue, forKey: field.key)
|
||||
return
|
||||
}
|
||||
profiles.setOverride(id, field.overlay, newValue)
|
||||
})
|
||||
}
|
||||
|
||||
/// The resolution row's tri-state, written as one act: a single control drives width, height
|
||||
/// and (where it exists) match-window, so all of them move together and one reset clears all
|
||||
/// three.
|
||||
func setResolution(width newWidth: Int? = nil, height newHeight: Int? = nil,
|
||||
matchWindow newMatch: Bool? = nil) {
|
||||
if let newWidth { scoped(SettingsFields.width).wrappedValue = newWidth }
|
||||
if let newHeight { scoped(SettingsFields.height).wrappedValue = newHeight }
|
||||
if let newMatch { scoped(SettingsFields.matchWindow).wrappedValue = newMatch }
|
||||
}
|
||||
|
||||
// MARK: - Override markers + per-row reset
|
||||
|
||||
/// Does the edited profile override this row? False in defaults scope, where there is nothing
|
||||
/// to inherit from.
|
||||
func isOverridden(_ name: String) -> Bool {
|
||||
guard let profile = activeProfile else { return false }
|
||||
return OverlayField.isOverridden(name, in: profile.overrides)
|
||||
}
|
||||
|
||||
/// Put a row back to inheriting. The ONLY way an override is removed — the model never infers
|
||||
/// "not overridden" from a value comparison.
|
||||
func resetOverride(_ name: String) {
|
||||
guard let id = scope.profileID else { return }
|
||||
profiles.clearOverride(id, field: name)
|
||||
}
|
||||
|
||||
/// The accent dot + "Reset" affordance a row carries while it overrides the defaults. Rendered
|
||||
/// inside `described`'s caption line, so it sits with the row it belongs to instead of in some
|
||||
/// separate list of exceptions.
|
||||
@ViewBuilder
|
||||
func overrideMarker(_ name: String) -> some View {
|
||||
if isOverridden(name) {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(Color.brand)
|
||||
.frame(width: 6, height: 6)
|
||||
// The adjacent text says the state; the dot is the at-a-glance version.
|
||||
.accessibilityHidden(true)
|
||||
Text("Overrides Default settings")
|
||||
.font(.geist(12, .medium, relativeTo: .caption2))
|
||||
.foregroundStyle(Color.brand)
|
||||
Button("Reset") { resetOverride(name) }
|
||||
.buttonStyle(.plain)
|
||||
.font(.geist(12, .medium, relativeTo: .caption2))
|
||||
.foregroundStyle(.tint)
|
||||
.accessibilityLabel("Reset to Default settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The scope switcher
|
||||
|
||||
/// "Editing: Default settings ▾" plus the edited profile's own management actions. One
|
||||
/// control, at the top of the surface, so the layer being edited is never ambiguous.
|
||||
@ViewBuilder
|
||||
var scopeSwitcher: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Menu {
|
||||
Picker("Editing", selection: scopeSelection) {
|
||||
Text("Default settings").tag(SettingsScope.defaults)
|
||||
ForEach(profiles.profiles) { profile in
|
||||
Text(profile.name).tag(SettingsScope.profile(profile.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
Divider()
|
||||
Button("New Profile…") {
|
||||
nameDraft = ""
|
||||
nameAction = .create
|
||||
}
|
||||
if let active = activeProfile {
|
||||
Button("Rename “\(active.name)”…") {
|
||||
nameDraft = active.name
|
||||
nameAction = .rename(active.id)
|
||||
}
|
||||
Button("Duplicate “\(active.name)”") {
|
||||
nameDraft = Self.copyName(of: active.name, in: profiles)
|
||||
nameAction = .duplicate(active.id)
|
||||
}
|
||||
Button("Delete “\(active.name)”…", role: .destructive) {
|
||||
profilePendingDelete = active
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
activeProfile?.name ?? "Default settings",
|
||||
systemImage: activeProfile == nil ? "gearshape" : "slider.horizontal.3")
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.fixedSize()
|
||||
Text(activeProfile == nil
|
||||
? "The settings every profile inherits from."
|
||||
: "Overrides Default settings for hosts that use this profile. "
|
||||
+ "Untouched rows keep following the defaults.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
// Name prompts (create / rename / duplicate) share one alert: they differ only in the
|
||||
// title and what happens with the name.
|
||||
.alert(nameAction?.title ?? "", isPresented: namePromptPresented) {
|
||||
TextField("Name", text: $nameDraft)
|
||||
Button("Cancel", role: .cancel) { nameAction = nil }
|
||||
Button(nameAction?.accept ?? "OK") { commitNamePrompt() }
|
||||
.disabled(!isNameAcceptable)
|
||||
} message: {
|
||||
Text(nameMessage)
|
||||
}
|
||||
// Deleting warns with what it will change — bindings fall back to Default settings and
|
||||
// pinned cards disappear (§6). Neither is an error, but neither should be a surprise.
|
||||
.alert(
|
||||
"Delete “\(profilePendingDelete?.name ?? "")”?",
|
||||
isPresented: Binding(
|
||||
get: { profilePendingDelete != nil },
|
||||
set: { if !$0 { profilePendingDelete = nil } }),
|
||||
presenting: profilePendingDelete
|
||||
) { profile in
|
||||
Button("Cancel", role: .cancel) { profilePendingDelete = nil }
|
||||
Button("Delete", role: .destructive) {
|
||||
profiles.delete(profile.id)
|
||||
scope = .defaults
|
||||
profilePendingDelete = nil
|
||||
}
|
||||
} message: { profile in
|
||||
Text(Self.deleteWarning(for: profile, in: profiles))
|
||||
}
|
||||
}
|
||||
|
||||
/// Switching scope is a plain state change — the rows are built by one code path and simply
|
||||
/// read the other layer, so there is nothing to commit on the way out.
|
||||
private var scopeSelection: Binding<SettingsScope> {
|
||||
Binding(get: { scope }, set: { scope = $0 })
|
||||
}
|
||||
|
||||
private var namePromptPresented: Binding<Bool> {
|
||||
Binding(get: { nameAction != nil }, set: { if !$0 { nameAction = nil } })
|
||||
}
|
||||
|
||||
/// Names are unique case-insensitively — two "Work"s make every menu ambiguous, and the
|
||||
/// deep-link grammar has to refuse an ambiguous reference rather than guess.
|
||||
private var isNameAcceptable: Bool {
|
||||
let name = nameDraft.trimmingCharacters(in: .whitespaces)
|
||||
guard !name.isEmpty else { return false }
|
||||
let except: String? = {
|
||||
if case .rename(let id) = nameAction { return id }
|
||||
return nil
|
||||
}()
|
||||
return !profiles.nameTaken(name, except: except)
|
||||
}
|
||||
|
||||
private var nameMessage: String {
|
||||
let name = nameDraft.trimmingCharacters(in: .whitespaces)
|
||||
if !name.isEmpty, !isNameAcceptable {
|
||||
return "Another profile is already called “\(name)”."
|
||||
}
|
||||
if case .create = nameAction {
|
||||
return "A new profile inherits everything. Change a setting here and only that "
|
||||
+ "setting is overridden."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func commitNamePrompt() {
|
||||
let name = nameDraft.trimmingCharacters(in: .whitespaces)
|
||||
guard !name.isEmpty, let action = nameAction else { return }
|
||||
switch action {
|
||||
case .create:
|
||||
scope = .profile(profiles.create(name: name).id)
|
||||
case .rename(let id):
|
||||
profiles.rename(id, to: name)
|
||||
case .duplicate(let id):
|
||||
if let copy = profiles.duplicate(id, name: name) { scope = .profile(copy.id) }
|
||||
}
|
||||
nameAction = nil
|
||||
}
|
||||
|
||||
/// "Game copy", "Game copy 2", … — the first name that isn't taken, so Duplicate never opens
|
||||
/// with a name the accept button refuses.
|
||||
private static func copyName(of name: String, in store: ProfileStore) -> String {
|
||||
let base = "\(name) copy"
|
||||
if !store.nameTaken(base) { return base }
|
||||
for n in 2...99 where !store.nameTaken("\(base) \(n)") { return "\(base) \(n)" }
|
||||
return base
|
||||
}
|
||||
|
||||
private static func deleteWarning(for profile: StreamProfile, in store: ProfileStore) -> String {
|
||||
let (bound, pinned) = store.usage(of: profile.id)
|
||||
var parts: [String] = []
|
||||
if bound > 0 {
|
||||
parts.append("\(bound) host\(bound == 1 ? "" : "s") will fall back to Default settings")
|
||||
}
|
||||
if pinned > 0 {
|
||||
parts.append("\(pinned) pinned card\(pinned == 1 ? "" : "s") will disappear")
|
||||
}
|
||||
guard !parts.isEmpty else { return "Nothing uses this profile." }
|
||||
return parts.joined(separator: ", ") + "."
|
||||
}
|
||||
}
|
||||
|
||||
/// The three name prompts the scope menu raises. One alert serves all of them; only the title,
|
||||
/// the accept verb and what happens with the name differ.
|
||||
enum ProfileNameAction: Equatable {
|
||||
case create
|
||||
case rename(String)
|
||||
case duplicate(String)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .create: return "New Profile"
|
||||
case .rename: return "Rename Profile"
|
||||
case .duplicate: return "Duplicate Profile"
|
||||
}
|
||||
}
|
||||
|
||||
var accept: String {
|
||||
switch self {
|
||||
case .create: return "Create"
|
||||
case .rename: return "Rename"
|
||||
case .duplicate: return "Duplicate"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
// the description is DYNAMIC — it explains the current choice. The only footers left are the
|
||||
// one-line "Applies from the next session." form notes.
|
||||
//
|
||||
// The SAME builders edit settings profiles (design/client-settings-profiles.md §5.1 —
|
||||
// SettingsView+Scope): a control's binding comes from `scoped(...)` rather than `@AppStorage`, so
|
||||
// it writes whichever layer the scope switcher selected, and `described(_:field:)` marks the row
|
||||
// when the edited profile overrides it. Rows that are NOT profileable — tier G (this device's
|
||||
// hardware and endpoints) and tier H (properties of a host) — are gated on `!inProfileScope` and
|
||||
// simply don't render there; sections that would end up empty don't either.
|
||||
//
|
||||
// Category map (SettingsCategory): General = session/app behavior, Display = everything about
|
||||
// the picture (resolution lives HERE), Input = touch/keyboard/mouse, Audio, Controllers, About.
|
||||
|
||||
@@ -29,11 +36,12 @@ extension SettingsView {
|
||||
#if os(iOS) || os(macOS)
|
||||
// Match-window (design/midstream-resolution-resize.md D1): follow the session
|
||||
// window/scene, renegotiating the host mode on a resize. Off → the explicit mode below.
|
||||
described(matchWindow
|
||||
described(effective.matchWindow
|
||||
? "The host resizes its output to follow this window — the picture stays "
|
||||
+ "pixel-exact (1:1) through every resize."
|
||||
: "Stream at the fixed mode below; a window at a different size shows it scaled.") {
|
||||
Toggle("Match window", isOn: $matchWindow)
|
||||
: "Stream at the fixed mode below; a window at a different size shows it scaled.",
|
||||
field: OverlayField.resolution) {
|
||||
Toggle("Match window", isOn: scoped(SettingsFields.matchWindow))
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@@ -42,14 +50,18 @@ extension SettingsView {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
#elseif os(macOS)
|
||||
HStack {
|
||||
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
||||
TextField(
|
||||
"Resolution", value: scoped(SettingsFields.width),
|
||||
format: .number.grouping(.never))
|
||||
Text("×")
|
||||
TextField("", value: $height, format: .number.grouping(.never))
|
||||
TextField("", value: scoped(SettingsFields.height), format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
}
|
||||
described("The host drives a real virtual output at exactly this size and refresh — "
|
||||
+ "true pixels, no scaling.") {
|
||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||
+ "true pixels, no scaling.", field: "refresh_hz") {
|
||||
TextField(
|
||||
"Refresh rate (Hz)", value: scoped(SettingsFields.refreshHz),
|
||||
format: .number.grouping(.never))
|
||||
}
|
||||
LabeledContent("") {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
@@ -91,10 +103,12 @@ extension SettingsView {
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
TextField("Width", value: scoped(SettingsFields.width),
|
||||
format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
TextField("Height", value: scoped(SettingsFields.height),
|
||||
format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
@@ -102,7 +116,8 @@ extension SettingsView {
|
||||
// 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))
|
||||
TextField("Hz", value: scoped(SettingsFields.refreshHz),
|
||||
format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
@@ -111,18 +126,19 @@ extension SettingsView {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
Picker("Refresh rate", selection: scoped(SettingsFields.refreshHz)) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
overrideMarker("refresh_hz")
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
Text("\(effective.refreshHz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,17 +160,21 @@ extension SettingsView {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// or the effective size simply isn't one of the presets (e.g. a value synced from a Mac, or a
|
||||
/// profile's own override) — so a non-preset mode stays editable without a persisted flag.
|
||||
private var isCustomResolution: Bool {
|
||||
customMode || !presetResolutionTags.contains("\(width)x\(height)")
|
||||
customMode || !presetResolutionTags.contains("\(effective.width)x\(effective.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)" },
|
||||
get: {
|
||||
isCustomResolution
|
||||
? Self.customResolutionTag
|
||||
: "\(effective.width)x\(effective.height)"
|
||||
},
|
||||
set: { tag in
|
||||
if tag == Self.customResolutionTag {
|
||||
customMode = true
|
||||
@@ -163,14 +183,13 @@ extension SettingsView {
|
||||
customMode = false
|
||||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||||
guard parts.count == 2 else { return }
|
||||
width = parts[0]
|
||||
height = parts[1]
|
||||
setResolution(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)
|
||||
SettingsOptions.refreshRates(including: effective.refreshHz)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -182,20 +201,21 @@ extension SettingsView {
|
||||
renderScaleRow
|
||||
bitrateRows
|
||||
#endif
|
||||
described("A preference — the host falls back if it can't encode it.") {
|
||||
Picker("Video codec", selection: $codec) {
|
||||
described("A preference — the host falls back if it can't encode it.",
|
||||
field: "codec") {
|
||||
Picker("Video codec", selection: scoped(SettingsFields.codec)) {
|
||||
ForEach(SettingsOptions.codecs, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
described("HDR10, when the host has HDR content and this display supports it. "
|
||||
+ "HEVC only; otherwise the stream stays SDR.") {
|
||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||
+ "HEVC only; otherwise the stream stays SDR.", field: "hdr_enabled") {
|
||||
Toggle("10-bit HDR", isOn: scoped(SettingsFields.hdrEnabled))
|
||||
}
|
||||
described("Sharper text and UI for desktop work, at more bandwidth. For games the "
|
||||
+ "bits are better spent at 4:2:0. HEVC only.") {
|
||||
Toggle("Full chroma (4:4:4)", isOn: $enable444)
|
||||
+ "bits are better spent at 4:2:0. HEVC only.", field: "enable_444") {
|
||||
Toggle("Full chroma (4:4:4)", isOn: scoped(SettingsFields.enable444))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,8 +225,8 @@ extension SettingsView {
|
||||
/// bandwidth AND client decode); < 1 renders under native (lighter). The presenter resamples the
|
||||
/// decoded frame to this display, so the multiplier is where the sharpness/cost trade-off lives.
|
||||
@ViewBuilder var renderScaleRow: some View {
|
||||
described(renderScaleDescription) {
|
||||
Picker("Render scale", selection: $renderScale) {
|
||||
described(renderScaleDescription, field: "render_scale") {
|
||||
Picker("Render scale", selection: scoped(SettingsFields.renderScale)) {
|
||||
ForEach(RenderScale.presets, id: \.self) { scale in
|
||||
Text(RenderScale.label(scale)).tag(scale)
|
||||
}
|
||||
@@ -220,11 +240,12 @@ extension SettingsView {
|
||||
private var renderScaleDescription: String {
|
||||
var text = "Above native supersamples for sharpness; below renders lighter on the host "
|
||||
+ "and the link."
|
||||
if renderScale != 1.0, !matchWindow {
|
||||
let settings = effective
|
||||
if settings.renderScale != 1.0, !settings.matchWindow {
|
||||
let mode = RenderScale.apply(
|
||||
baseWidth: width, baseHeight: height,
|
||||
scale: renderScale,
|
||||
maxDimension: RenderScale.maxDimension(codec: codec))
|
||||
baseWidth: settings.width, baseHeight: settings.height,
|
||||
scale: settings.renderScale,
|
||||
maxDimension: RenderScale.maxDimension(codec: settings.codec))
|
||||
text += " Host renders \(Int(mode.width))×\(Int(mode.height)); this device scales "
|
||||
+ "it to your display."
|
||||
}
|
||||
@@ -234,20 +255,21 @@ extension SettingsView {
|
||||
/// The automatic-bitrate toggle + manual slider (and the >1 Gbps warning) rows.
|
||||
@ViewBuilder private var bitrateRows: some View {
|
||||
described("The host's default 20 Mbps, clamped to what it supports. Turn off to set a "
|
||||
+ "fixed rate — a host card's context menu has a network speed test.") {
|
||||
+ "fixed rate — a host card's context menu has a network speed test.",
|
||||
field: "bitrate_kbps") {
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
}
|
||||
if bitrateKbps != 0 {
|
||||
if effective.bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
}
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: effective.bitrateKbps))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .trailing)
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
if effective.bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
@@ -263,12 +285,12 @@ extension SettingsView {
|
||||
// buffer). The stage ladder survives only as the hidden PUNKTFUNK_PRESENTER debug env lever.
|
||||
@ViewBuilder var presentationSection: some View {
|
||||
Section("Presentation") {
|
||||
described(presentPriority == "smooth"
|
||||
described(effective.presentPriority == "smooth"
|
||||
? "A small frame buffer evens out network hiccups, at the buffer's worth of "
|
||||
+ "added display latency."
|
||||
: "Every frame shows the moment the display can take it — a network hiccup is "
|
||||
+ "an occasional repeated or skipped frame.") {
|
||||
Picker("Prioritize", selection: $presentPriority) {
|
||||
+ "an occasional repeated or skipped frame.", field: "present_priority") {
|
||||
Picker("Prioritize", selection: scoped(SettingsFields.presentPriority)) {
|
||||
ForEach(SettingsOptions.presentPriorities, id: \.tag) { option in
|
||||
Text(option.tag == SettingsOptions.presentPriorityDefault
|
||||
? "\(option.label) (default)" : option.label)
|
||||
@@ -276,11 +298,14 @@ extension SettingsView {
|
||||
}
|
||||
}
|
||||
}
|
||||
if presentPriority == "smooth" {
|
||||
if effective.presentPriority == "smooth" {
|
||||
described("Frames held back — each absorbs about one refresh of jitter and "
|
||||
+ "adds one refresh of delay.") {
|
||||
Picker("Buffer", selection: $smoothBuffer) {
|
||||
ForEach(SettingsOptions.smoothBuffers(refreshHz: hz), id: \.tag) { option in
|
||||
+ "adds one refresh of delay.", field: "smooth_buffer") {
|
||||
Picker("Buffer", selection: scoped(SettingsFields.smoothBuffer)) {
|
||||
ForEach(
|
||||
SettingsOptions.smoothBuffers(refreshHz: effective.refreshHz),
|
||||
id: \.tag
|
||||
) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
@@ -289,28 +314,28 @@ extension SettingsView {
|
||||
// Non-tvOS: the Apple TV drives a fixed HDMI mode, so there's no adaptive refresh.
|
||||
#if !os(tvOS)
|
||||
described("A ProMotion or adaptive-sync display follows the stream's rate — "
|
||||
+ "smoother motion. No effect on fixed-refresh displays.") {
|
||||
Toggle("Allow VRR", isOn: $allowVRR)
|
||||
+ "smoother motion. No effect on fixed-refresh displays.", field: "allow_vrr") {
|
||||
Toggle("Allow VRR", isOn: scoped(SettingsFields.allowVRR))
|
||||
}
|
||||
#endif
|
||||
// macOS-only: iOS/tvOS layers always present on the display's vsync, so the choice
|
||||
// only exists on the Mac (the layer's own sync stays off — see MetalVideoPresenter).
|
||||
#if os(macOS)
|
||||
described("Flips align to the display's refresh — even pacing, up to one refresh "
|
||||
+ "of added latency. Off shows frames as soon as they're ready.") {
|
||||
Toggle("V-Sync", isOn: $vsync)
|
||||
+ "of added latency. Off shows frames as soon as they're ready.", field: "vsync") {
|
||||
Toggle("V-Sync", isOn: scoped(SettingsFields.vsync))
|
||||
}
|
||||
// The DCP swapID-panic mitigation's user handle (see DefaultsKey.windowedSafePresent
|
||||
// for the saga). Default ON: turning it off re-arms a WHOLE-MACHINE kernel panic on
|
||||
// affected setups, so the caption says so in plain words.
|
||||
described(windowedSafePresent
|
||||
described(effective.windowedSafePresent
|
||||
? "Windowed streams present in step with the system compositor — avoids a macOS "
|
||||
+ "display-driver crash seen on high-refresh displays, at a small latency "
|
||||
+ "cost. Fullscreen always uses the fastest path."
|
||||
: "Windowed streams use the fastest present path. On some high-refresh setups "
|
||||
+ "this can crash macOS itself (kernel panic) — turn back on if your Mac "
|
||||
+ "restarts during windowed streaming.") {
|
||||
Toggle("Safe windowed presentation", isOn: $windowedSafePresent)
|
||||
+ "restarts during windowed streaming.", field: "windowed_safe_present") {
|
||||
Toggle("Safe windowed presentation", isOn: scoped(SettingsFields.windowedSafePresent))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -321,8 +346,9 @@ extension SettingsView {
|
||||
@ViewBuilder var hostOutputSection: some View {
|
||||
Section {
|
||||
described("The backend the host uses for its virtual output. A specific choice "
|
||||
+ "falls back to auto-detection when that backend isn't available.") {
|
||||
Picker("Compositor", selection: $compositor) {
|
||||
+ "falls back to auto-detection when that backend isn't available.",
|
||||
field: "compositor") {
|
||||
Picker("Compositor", selection: scoped(SettingsFields.compositor)) {
|
||||
ForEach(SettingsOptions.compositors, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
@@ -340,36 +366,56 @@ extension SettingsView {
|
||||
|
||||
// MARK: - General: Session
|
||||
|
||||
/// Empty in profile scope everywhere but macOS: auto-wake is a property of the host and this
|
||||
/// network, and background keep-alive is a property of this device — neither is something
|
||||
/// "Game" and "Work" would ever differ on (§3, tiers H and G).
|
||||
private var showsSessionSection: Bool {
|
||||
#if os(macOS)
|
||||
return true
|
||||
#else
|
||||
return !inProfileScope
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder var sessionSection: some View {
|
||||
Section("Session") {
|
||||
#if os(macOS)
|
||||
described("Go fullscreen when a session starts; return to a window on the host "
|
||||
+ "list.") {
|
||||
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
||||
}
|
||||
#endif
|
||||
described("Connecting to a saved host that's offline sends Wake-on-LAN and waits "
|
||||
+ "for it to boot. Turn off if hosts behind a VPN look offline when they "
|
||||
+ "aren't.") {
|
||||
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||
}
|
||||
#if os(iOS)
|
||||
described("Audio and the connection stay live after you switch away; video pauses "
|
||||
+ "to save power and resumes instantly when you return. Off, backgrounding "
|
||||
+ "freezes the session.") {
|
||||
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
||||
}
|
||||
if backgroundKeepAlive {
|
||||
described("Ends a backgrounded session so it can't run down the battery.") {
|
||||
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
||||
Text("1 minute").tag(1)
|
||||
Text("5 minutes").tag(5)
|
||||
Text("10 minutes").tag(10)
|
||||
Text("30 minutes").tag(30)
|
||||
if showsSessionSection {
|
||||
Section("Session") {
|
||||
#if os(macOS)
|
||||
described("Go fullscreen when a session starts; return to a window on the host "
|
||||
+ "list.", field: "fullscreen_on_stream") {
|
||||
Toggle(
|
||||
"Fullscreen while streaming",
|
||||
isOn: scoped(SettingsFields.fullscreenWhileStreaming))
|
||||
}
|
||||
#endif
|
||||
if !inProfileScope {
|
||||
described("Connecting to a saved host that's offline sends Wake-on-LAN and "
|
||||
+ "waits for it to boot. Turn off if hosts behind a VPN look offline "
|
||||
+ "when they aren't.") {
|
||||
Toggle("Auto-wake on connect", isOn: $autoWakeEnabled)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
if !inProfileScope {
|
||||
described("Audio and the connection stay live after you switch away; video "
|
||||
+ "pauses to save power and resumes instantly when you return. Off, "
|
||||
+ "backgrounding freezes the session.") {
|
||||
Toggle("Keep streaming in background", isOn: $backgroundKeepAlive)
|
||||
}
|
||||
if backgroundKeepAlive {
|
||||
described("Ends a backgrounded session so it can't run down the "
|
||||
+ "battery.") {
|
||||
Picker("Disconnect after", selection: $backgroundTimeoutMinutes) {
|
||||
Text("1 minute").tag(1)
|
||||
Text("5 minutes").tag(5)
|
||||
Text("10 minutes").tag(10)
|
||||
Text("30 minutes").tag(30)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,29 +423,37 @@ extension SettingsView {
|
||||
|
||||
@ViewBuilder var overlaySection: some View {
|
||||
Section("Statistics") {
|
||||
described(Self.statisticsDescription) {
|
||||
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
||||
described(Self.statisticsDescription, field: "stats_verbosity") {
|
||||
Picker("Statistics overlay", selection: scoped(SettingsFields.statsVerbosity)) {
|
||||
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
||||
Text(tier.label).tag(tier.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
Picker("Position", selection: $hudPlacement) {
|
||||
ForEach(HUDPlacement.allCases) { placement in
|
||||
Text(placement.label).tag(placement.rawValue)
|
||||
// Which corner the overlay sits in is a property of this device's screen, not of a
|
||||
// profile (tier G).
|
||||
if !inProfileScope {
|
||||
Picker("Position", selection: $hudPlacement) {
|
||||
ForEach(HUDPlacement.allCases) { placement in
|
||||
Text(placement.label).tag(placement.rawValue)
|
||||
}
|
||||
}
|
||||
.disabled(effective.statsVerbosity == StatsVerbosity.off.rawValue)
|
||||
}
|
||||
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - General: Library
|
||||
|
||||
@ViewBuilder var librarySection: some View {
|
||||
Section("Library") {
|
||||
described("Adds “Browse Library…” to paired hosts — list their Steam and custom "
|
||||
+ "games and launch one directly. No extra host setup.") {
|
||||
Toggle("Show game library", isOn: $libraryEnabled)
|
||||
// An app-level feature switch for this device (tier G) — the whole section collapses in
|
||||
// profile scope rather than rendering an empty group.
|
||||
if !inProfileScope {
|
||||
Section("Library") {
|
||||
described("Adds “Browse Library…” to paired hosts — list their Steam and custom "
|
||||
+ "games and launch one directly. No extra host setup.") {
|
||||
Toggle("Show game library", isOn: $libraryEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,14 +465,16 @@ extension SettingsView {
|
||||
/// mouse/trackpad for relative movement (games) vs forward an absolute cursor position.
|
||||
@ViewBuilder var pointerSection: some View {
|
||||
Section("Touch & pointer") {
|
||||
described(touchModeDescription) {
|
||||
Picker("Touch input", selection: $touchMode) {
|
||||
described(touchModeDescription, field: "touch_mode") {
|
||||
Picker("Touch input", selection: scoped(SettingsFields.touchMode)) {
|
||||
Text("Trackpad").tag(TouchInputMode.trackpad.rawValue)
|
||||
Text("Direct pointer").tag(TouchInputMode.pointer.rawValue)
|
||||
Text("Touch passthrough").tag(TouchInputMode.touch.rawValue)
|
||||
}
|
||||
}
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
// Whether a hardware mouse attached to THIS iPad gets locked is a fact about this
|
||||
// device's input hardware (tier G), not about how a host is streamed.
|
||||
if !inProfileScope, UIDevice.current.userInterfaceIdiom == .pad {
|
||||
described("Locks a hardware mouse for relative mouse-look in games; off sends "
|
||||
+ "absolute positions. Needs the stream fullscreen and frontmost.") {
|
||||
Toggle("Capture pointer for games", isOn: $pointerCapture)
|
||||
@@ -430,7 +486,7 @@ extension SettingsView {
|
||||
/// The SELECTED touch mode explained — dynamic, so the caption always describes what the
|
||||
/// picker currently does instead of narrating all three modes at once.
|
||||
private var touchModeDescription: String {
|
||||
switch TouchInputMode(rawValue: touchMode) ?? .trackpad {
|
||||
switch TouchInputMode(rawValue: effective.touchMode) ?? .trackpad {
|
||||
case .trackpad:
|
||||
return "Your finger drives the host cursor like a laptop trackpad — tap to click, "
|
||||
+ "two-finger tap right-clicks, two-finger drag scrolls, tap-and-drag holds."
|
||||
@@ -448,22 +504,26 @@ extension SettingsView {
|
||||
@ViewBuilder var inputSection: some View {
|
||||
Section("Keyboard & mouse") {
|
||||
#if os(macOS)
|
||||
described(mouseModeDescription) {
|
||||
Picker("Mouse input", selection: $mouseMode) {
|
||||
described(mouseModeDescription, field: "mouse_mode") {
|
||||
Picker("Mouse input", selection: scoped(SettingsFields.mouseMode)) {
|
||||
Text("Capture (games)").tag(MouseInputMode.capture.rawValue)
|
||||
Text("Desktop (absolute)").tag(MouseInputMode.desktop.rawValue)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
described((ModifierLayout(rawValue: modifierLayout) ?? .mac).detail) {
|
||||
Picker("Modifier keys", selection: $modifierLayout) {
|
||||
described(
|
||||
(ModifierLayout(rawValue: effective.modifierLayout) ?? .mac).detail,
|
||||
field: "modifier_layout"
|
||||
) {
|
||||
Picker("Modifier keys", selection: scoped(SettingsFields.modifierLayout)) {
|
||||
ForEach(ModifierLayout.allCases, id: \.self) { layout in
|
||||
Text(layout.label).tag(layout.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
described("Reverses the wheel and trackpad scroll direction sent to the host.") {
|
||||
Toggle("Invert scroll direction", isOn: $invertScroll)
|
||||
described("Reverses the wheel and trackpad scroll direction sent to the host.",
|
||||
field: "invert_scroll") {
|
||||
Toggle("Invert scroll direction", isOn: scoped(SettingsFields.invertScroll))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,7 +531,7 @@ extension SettingsView {
|
||||
#if os(macOS)
|
||||
/// The SELECTED mouse model explained — dynamic, like the touch-mode caption.
|
||||
private var mouseModeDescription: String {
|
||||
switch MouseInputMode(rawValue: mouseMode) ?? .capture {
|
||||
switch MouseInputMode(rawValue: effective.mouseMode) ?? .capture {
|
||||
case .capture:
|
||||
return "The pointer locks to the stream and sends relative motion — best for "
|
||||
+ "games. ⌃⌥⇧M switches live; applies from the next capture otherwise."
|
||||
@@ -487,54 +547,61 @@ extension SettingsView {
|
||||
|
||||
@ViewBuilder var audioSection: some View {
|
||||
Section {
|
||||
described("The speaker layout requested from the host.") {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
described("The speaker layout requested from the host.", field: "audio_channels") {
|
||||
Picker("Audio channels", selection: scoped(SettingsFields.audioChannels)) {
|
||||
ForEach(SettingsOptions.audioChannels, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
described("Host audio plays through this device; System default follows your "
|
||||
+ "Mac's output changes.") {
|
||||
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)
|
||||
// Which speaker THIS Mac plays through is this device's audio routing (tier G).
|
||||
if !inProfileScope {
|
||||
described("Host audio plays through this device; System default follows your "
|
||||
+ "Mac's output changes.") {
|
||||
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
|
||||
described("This device's microphone feeds the host's virtual mic.") {
|
||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||
described("This device's microphone feeds the host's virtual mic.",
|
||||
field: "mic_enabled") {
|
||||
Toggle("Send microphone to the host", isOn: scoped(SettingsFields.micEnabled))
|
||||
}
|
||||
#if os(macOS)
|
||||
Picker("Microphone", selection: $micUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(inputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
if !inProfileScope {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if !micUID.isEmpty,
|
||||
!inputDevices.contains(where: { $0.uid == micUID }) {
|
||||
Text("Unavailable device").tag(micUID)
|
||||
}
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the user
|
||||
// pick it. Auto sums every channel (a lone hot mic still passes at full level).
|
||||
if micChannelCount > 1 {
|
||||
described("Pick the input your mic is on; Auto sums every channel.") {
|
||||
Picker("Microphone channel", selection: $micChannel) {
|
||||
Text("Auto (all channels)").tag(0)
|
||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||
Text("Channel \(ch)").tag(ch)
|
||||
.disabled(!effective.micEnabled)
|
||||
// Multi-channel interfaces only: the mic sits on ONE discrete input, so let the
|
||||
// user pick it. Auto sums every channel (a lone hot mic still passes at full
|
||||
// level).
|
||||
if micChannelCount > 1 {
|
||||
described("Pick the input your mic is on; Auto sums every channel.") {
|
||||
Picker("Microphone channel", selection: $micChannel) {
|
||||
Text("Auto (all channels)").tag(0)
|
||||
ForEach(1...micChannelCount, id: \.self) { ch in
|
||||
Text("Channel \(ch)").tag(ch)
|
||||
}
|
||||
}
|
||||
.disabled(!effective.micEnabled)
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -551,25 +618,30 @@ extension SettingsView {
|
||||
|
||||
@ViewBuilder var controllersSection: some View {
|
||||
Section {
|
||||
if gamepads.controllers.isEmpty {
|
||||
Text("No controllers detected")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(gamepads.controllers) { controller in
|
||||
controllerRow(controller)
|
||||
// Which physical pad this device forwards, and what its own haptics do, are facts
|
||||
// about THIS device (tier G) — only the virtual pad the host creates is profileable.
|
||||
if !inProfileScope {
|
||||
if gamepads.controllers.isEmpty {
|
||||
Text("No controllers detected")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(gamepads.controllers) { controller in
|
||||
controllerRow(controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
described("One controller is forwarded as player 1 — Automatic picks the most "
|
||||
+ "recently connected.") {
|
||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||
ForEach(controllerOptions, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
described("One controller is forwarded as player 1 — Automatic picks the most "
|
||||
+ "recently connected.") {
|
||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||
ForEach(controllerOptions, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
described("The virtual pad created on the host. Automatic matches your controller "
|
||||
+ "— a DualSense keeps adaptive triggers, lightbar, touchpad and motion.") {
|
||||
Picker("Controller type", selection: $gamepadType) {
|
||||
+ "— a DualSense keeps adaptive triggers, lightbar, touchpad and motion.",
|
||||
field: "gamepad") {
|
||||
Picker("Controller type", selection: scoped(SettingsFields.gamepadType)) {
|
||||
ForEach(SettingsOptions.padTypes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
@@ -577,7 +649,7 @@ extension SettingsView {
|
||||
}
|
||||
#if os(iOS)
|
||||
// iPhone only in practice: hidden where the device itself can't play haptics (iPad).
|
||||
if CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
if !inProfileScope, CHHapticEngine.capabilitiesForHardware().supportsHaptics {
|
||||
described("Plays player 1's rumble on the phone's own Taptic Engine — for "
|
||||
+ "clip-on controllers without motors of their own.") {
|
||||
Toggle("Rumble on this iPhone", isOn: $rumbleOnDevice)
|
||||
@@ -585,16 +657,20 @@ extension SettingsView {
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
described("With a controller connected, the host list and library switch to a "
|
||||
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
||||
+ "browser.") {
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
if !inProfileScope {
|
||||
described("With a controller connected, the host list and library switch to a "
|
||||
+ "controller-friendly layout — larger focus targets, a swipeable cover "
|
||||
+ "browser.") {
|
||||
Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if DEBUG && !os(tvOS)
|
||||
Button("Test Controller…") { showControllerTest = true }
|
||||
.disabled(gamepads.active == nil)
|
||||
.sheet(isPresented: $showControllerTest) { ControllerTestView() }
|
||||
if !inProfileScope {
|
||||
Button("Test Controller…") { showControllerTest = true }
|
||||
.disabled(gamepads.active == nil)
|
||||
.sheet(isPresented: $showControllerTest) { ControllerTestView() }
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text("Controllers")
|
||||
|
||||
@@ -16,9 +16,12 @@ extension SettingsView {
|
||||
/// can't match to its field is one nobody reads. Keep captions to one or two sentences; when
|
||||
/// a picker's meaning depends on the selection, pass a DYNAMIC string describing the current
|
||||
/// choice.
|
||||
/// `field` is the overlay's name for this row (see `SettingsField`). Passing it puts the
|
||||
/// override marker + Reset in the caption line while a profile is being edited — with the row
|
||||
/// it belongs to, which is the only place the state is legible.
|
||||
@ViewBuilder
|
||||
func described<Content: View>(
|
||||
_ caption: String, @ViewBuilder content: () -> Content
|
||||
_ caption: String, field: String? = nil, @ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
content()
|
||||
@@ -30,6 +33,9 @@ extension SettingsView {
|
||||
// its text right up to the control column (toggles especially), reading as one
|
||||
// colliding block. ~46 chars/line also just measures better.
|
||||
.frame(maxWidth: 360, alignment: .leading)
|
||||
if let field {
|
||||
overrideMarker(field)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
@@ -53,19 +59,23 @@ extension SettingsView {
|
||||
+ "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.
|
||||
/// `bitrateKbps == 0` is Automatic; switching to manual lands on the host default. Scoped, so
|
||||
/// flipping it in a profile records the override there rather than moving the global.
|
||||
var automaticBitrate: Binding<Bool> {
|
||||
Binding(
|
||||
get: { bitrateKbps == 0 },
|
||||
set: { bitrateKbps = $0 ? 0 : 20_000 })
|
||||
let bitrate = scoped(SettingsFields.bitrateKbps)
|
||||
return Binding(
|
||||
get: { bitrate.wrappedValue == 0 },
|
||||
set: { bitrate.wrappedValue = $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(
|
||||
let bitrate = scoped(SettingsFields.bitrateKbps)
|
||||
return Binding(
|
||||
get: {
|
||||
let v = Double(bitrateKbps).clamped(Self.minSliderKbps, Self.maxSliderKbps)
|
||||
let v = Double(bitrate.wrappedValue)
|
||||
.clamped(Self.minSliderKbps, Self.maxSliderKbps)
|
||||
return log(v / Self.minSliderKbps)
|
||||
/ log(Self.maxSliderKbps / Self.minSliderKbps)
|
||||
},
|
||||
@@ -73,7 +83,7 @@ extension SettingsView {
|
||||
let raw = Self.minSliderKbps
|
||||
* pow(Self.maxSliderKbps / Self.minSliderKbps, pos)
|
||||
let mag = pow(10, floor(log10(raw)) - 1)
|
||||
bitrateKbps = Int((raw / mag).rounded() * mag)
|
||||
bitrate.wrappedValue = Int((raw / mag).rounded() * mag)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,15 +165,16 @@ extension SettingsView {
|
||||
#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
|
||||
setResolution(
|
||||
width: Int(screen.frame.width * scale), height: Int(screen.frame.height * scale))
|
||||
scoped(SettingsFields.refreshHz).wrappedValue = 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
|
||||
setResolution(
|
||||
width: Int(max(bounds.width, bounds.height)),
|
||||
height: Int(min(bounds.width, bounds.height)))
|
||||
scoped(SettingsFields.refreshHz).wrappedValue = 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
|
||||
|
||||
@@ -21,6 +21,14 @@ import SwiftUI
|
||||
@MainActor
|
||||
struct SettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
// Which LAYER this surface is editing (SettingsView+Scope): the global defaults, or one
|
||||
// profile's overrides. tvOS keeps defaults-only in v1 — controller-first surfaces honor
|
||||
// profiles and render pinned cards, but don't edit them (design §5.4).
|
||||
@ObservedObject var profiles = ProfileStore.shared
|
||||
@State var scope: SettingsScope = .defaults
|
||||
@State var nameAction: ProfileNameAction?
|
||||
@State var nameDraft = ""
|
||||
@State var profilePendingDelete: StreamProfile?
|
||||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||||
@@ -126,6 +134,19 @@ struct SettingsView: View {
|
||||
|
||||
#if os(macOS)
|
||||
private var macBody: some View {
|
||||
// The scope control heads the window — above the tabs, because it is about which layer
|
||||
// every tab is editing, not about any one of them.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
scopeSwitcher
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 10)
|
||||
macTabs
|
||||
}
|
||||
.frame(width: 500, height: 580)
|
||||
}
|
||||
|
||||
private var macTabs: some View {
|
||||
// Tab map mirrors SettingsCategory: General = session/app behavior, Display = the whole
|
||||
// picture (resolution lives here), Input = keyboard & mouse.
|
||||
TabView {
|
||||
@@ -182,7 +203,6 @@ struct SettingsView: View {
|
||||
AcknowledgementsView()
|
||||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
}
|
||||
.frame(width: 500, height: 520)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -192,6 +212,12 @@ struct SettingsView: View {
|
||||
private var iosBody: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
List(selection: $settingsSelection) {
|
||||
// The scope control heads the category list: on iPhone this is the screen you
|
||||
// start on, and on iPad it stays visible beside whichever category is open — so
|
||||
// the layer being edited is never off screen while you edit it.
|
||||
Section {
|
||||
scopeSwitcher
|
||||
}
|
||||
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
|
||||
|
||||
@@ -13,6 +13,12 @@ import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class ProfileStore: ObservableObject {
|
||||
/// One catalog for the whole app. Unlike `HostStore` (which ContentView owns and hands down),
|
||||
/// the settings surface reaches this from a SEPARATE macOS `Settings` scene, where no parent
|
||||
/// can pass it — and two instances would mean editing a profile in Preferences while the host
|
||||
/// grid still shows the old one. Same shape as `GamepadManager.shared`.
|
||||
static let shared = ProfileStore()
|
||||
|
||||
@Published private(set) var catalog: ProfileCatalog {
|
||||
didSet { catalog.save() }
|
||||
}
|
||||
@@ -78,13 +84,24 @@ final class ProfileStore: ObservableObject {
|
||||
|
||||
/// How the delete warning counts what it is about to change: hosts bound to this profile and
|
||||
/// pinned cards that will disappear.
|
||||
func usage(of id: String, hosts: [StoredHost]) -> (bound: Int, pinned: Int) {
|
||||
(
|
||||
func usage(of id: String) -> (bound: Int, pinned: Int) {
|
||||
let hosts = Self.savedHosts()
|
||||
return (
|
||||
hosts.filter { $0.profileID == id }.count,
|
||||
hosts.filter { ($0.pinnedProfileIDs ?? []).contains(id) }.count
|
||||
)
|
||||
}
|
||||
|
||||
/// Saved hosts straight from the shared store. The settings surface owns no `HostStore` — it
|
||||
/// only needs to COUNT what a delete is about to change, and reading the same App-Group blob
|
||||
/// the widget reads beats threading a store through a separate macOS Settings scene.
|
||||
static func savedHosts() -> [StoredHost] {
|
||||
guard let data = AppGroup.defaults.data(forKey: DefaultsKey.hosts),
|
||||
let hosts = try? JSONDecoder().decode([StoredHost].self, from: data)
|
||||
else { return [] }
|
||||
return hosts
|
||||
}
|
||||
|
||||
// MARK: - Overrides
|
||||
|
||||
/// Record an override, always by explicit write — never by comparing the new value against
|
||||
|
||||
Reference in New Issue
Block a user