diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 49ac4a5d..aa59b97f 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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. diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift new file mode 100644 index 00000000..1aeb001d --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -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 { + /// 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 + let effective: KeyPath +} + +/// 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 { + .init(name: "refresh_hz", key: DefaultsKey.streamHz, + overlay: \.refreshHz, effective: \.refreshHz) + } + static var matchWindow: SettingsField { + // 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 { + .init(name: OverlayField.resolution, key: DefaultsKey.streamWidth, + overlay: \.width, effective: \.width) + } + static var height: SettingsField { + .init(name: OverlayField.resolution, key: DefaultsKey.streamHeight, + overlay: \.height, effective: \.height) + } + static var renderScale: SettingsField { + .init(name: "render_scale", key: DefaultsKey.renderScale, + overlay: \.renderScale, effective: \.renderScale) + } + static var bitrateKbps: SettingsField { + .init(name: "bitrate_kbps", key: DefaultsKey.bitrateKbps, + overlay: \.bitrateKbps, effective: \.bitrateKbps) + } + static var codec: SettingsField { + .init(name: "codec", key: DefaultsKey.codec, overlay: \.codec, effective: \.codec) + } + static var hdrEnabled: SettingsField { + .init(name: "hdr_enabled", key: DefaultsKey.hdrEnabled, + overlay: \.hdrEnabled, effective: \.hdrEnabled) + } + static var enable444: SettingsField { + .init(name: "enable_444", key: DefaultsKey.enable444, + overlay: \.enable444, effective: \.enable444) + } + static var compositor: SettingsField { + .init(name: "compositor", key: DefaultsKey.compositor, + overlay: \.compositor, effective: \.compositor) + } + static var audioChannels: SettingsField { + .init(name: "audio_channels", key: DefaultsKey.audioChannels, + overlay: \.audioChannels, effective: \.audioChannels) + } + static var micEnabled: SettingsField { + .init(name: "mic_enabled", key: DefaultsKey.micEnabled, + overlay: \.micEnabled, effective: \.micEnabled) + } + static var touchMode: SettingsField { + .init(name: "touch_mode", key: DefaultsKey.touchMode, + overlay: \.touchMode, effective: \.touchMode) + } + static var mouseMode: SettingsField { + .init(name: "mouse_mode", key: DefaultsKey.mouseMode, + overlay: \.mouseMode, effective: \.mouseMode) + } + static var invertScroll: SettingsField { + .init(name: "invert_scroll", key: DefaultsKey.invertScroll, + overlay: \.invertScroll, effective: \.invertScroll) + } + static var modifierLayout: SettingsField { + .init(name: "modifier_layout", key: DefaultsKey.modifierLayout, + overlay: \.modifierLayout, effective: \.modifierLayout) + } + static var gamepadType: SettingsField { + .init(name: "gamepad", key: DefaultsKey.gamepadType, + overlay: \.gamepadType, effective: \.gamepadType) + } + static var statsVerbosity: SettingsField { + .init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity, + overlay: \.statsVerbosity, effective: \.statsVerbosity) + } + static var fullscreenWhileStreaming: SettingsField { + .init(name: "fullscreen_on_stream", key: DefaultsKey.fullscreenWhileStreaming, + overlay: \.fullscreenWhileStreaming, effective: \.fullscreenWhileStreaming) + } + static var presentPriority: SettingsField { + .init(name: "present_priority", key: DefaultsKey.presentPriority, + overlay: \.presentPriority, effective: \.presentPriority) + } + static var smoothBuffer: SettingsField { + .init(name: "smooth_buffer", key: DefaultsKey.smoothBuffer, + overlay: \.smoothBuffer, effective: \.smoothBuffer) + } + static var vsync: SettingsField { + .init(name: "vsync", key: DefaultsKey.vsync, overlay: \.vsync, effective: \.vsync) + } + static var allowVRR: SettingsField { + .init(name: "allow_vrr", key: DefaultsKey.allowVRR, + overlay: \.allowVRR, effective: \.allowVRR) + } + static var windowedSafePresent: SettingsField { + .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(_ field: SettingsField) -> Binding { + 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 { + Binding(get: { scope }, set: { scope = $0 }) + } + + private var namePromptPresented: Binding { + 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" + } + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 471a21ff..cec9749f 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -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 { 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") diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift index dab339d2..20beb321 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift @@ -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( - _ 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 { - 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 { - 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 diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 56174796..572624e8 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift index aa1ee041..aabbf168 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift @@ -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