42d1c74663
apple / swift (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
The mic uplink handed the host pure digital silence on a multi-channel interface: AVAudioConverter's N→stereo downmix takes channels 0/1, but a pro interface puts the mic on ONE higher discrete channel. Fold the input to a mono bus ourselves instead — pick the mic's channel (or sum all) and resample that to the encoder's 48 kHz stereo, so the silent 0/1 downmix never happens. - New "Microphone channel" setting (macOS): Auto (sum every channel — a lone hot mic passes at full level) or pin 1-based channel N. Picker appears only for multi-channel devices, driven by the device's input channel count. - Diagnostics that make this class of failure self-naming next session: log the actual live capture device + format + fold mode, warn on a silent UID fallback, and a one-shot silence tripwire on the EXTRACTED signal (WARN on 10 s of zeros, else peak dBFS). - foldToMono extracted as a pure, unit-tested helper (pin / sum-clamp x interleaved / deinterleaved / mono / out-of-range). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
381 lines
16 KiB
Swift
381 lines
16 KiB
Swift
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||
// there is no scaling anywhere in the pipeline.
|
||
//
|
||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||
// `audioSection`, …) are shared across all three so a setting is defined exactly once — they
|
||
// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift.
|
||
|
||
#if os(macOS)
|
||
import AppKit
|
||
#endif
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
|
||
@MainActor
|
||
struct SettingsView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@AppStorage(DefaultsKey.streamWidth) var width = 1920
|
||
@AppStorage(DefaultsKey.streamHeight) var height = 1080
|
||
@AppStorage(DefaultsKey.streamHz) var hz = 60
|
||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||
@AppStorage(DefaultsKey.presenter) var presenter = "stage2"
|
||
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
||
@AppStorage(DefaultsKey.enable444) var enable444 = true
|
||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
|
||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
||
@AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true
|
||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||
@ObservedObject var gamepads = GamepadManager.shared
|
||
#if !os(tvOS)
|
||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||
#endif
|
||
#if DEBUG && !os(tvOS)
|
||
@State var showControllerTest = false
|
||
#endif
|
||
#if os(iOS)
|
||
@AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true
|
||
@AppStorage(DefaultsKey.touchMode) var touchMode = TouchInputMode.trackpad.rawValue
|
||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||
// General on iPad (a two-column layout should never open with an empty detail).
|
||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||
@State private var settingsSelection: SettingsCategory?
|
||
// Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen
|
||
// — not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts
|
||
// .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible.
|
||
@State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
|
||
// Sticky once the wheel lands on "Custom…", so editing a width/height that briefly equals a
|
||
// preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even
|
||
// when this is false (see `isCustomResolution`), so it survives relaunches without persisting.
|
||
@State var customMode = false
|
||
#endif
|
||
#if os(macOS)
|
||
@AppStorage(DefaultsKey.speakerUID) var speakerUID = ""
|
||
@AppStorage(DefaultsKey.micUID) var micUID = ""
|
||
@AppStorage(DefaultsKey.micChannel) var micChannel = 0
|
||
@State var outputDevices: [AudioDevice] = []
|
||
@State var inputDevices: [AudioDevice] = []
|
||
// Input channels of the selected mic — drives the "Microphone channel" picker, which only
|
||
// appears for a multi-channel interface (>1). 0 until the Audio tab loads it.
|
||
@State var micChannelCount = 0
|
||
#endif
|
||
|
||
#if os(iOS)
|
||
/// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on
|
||
/// General via `onAppear`). The screenshot harness passes an explicit category so the captured
|
||
/// shot opens on a real settings page (a populated detail) rather than the bare category list.
|
||
init(initialCategory: SettingsCategory? = nil) {
|
||
_settingsSelection = State(initialValue: initialCategory)
|
||
}
|
||
#endif
|
||
|
||
var body: some View {
|
||
#if os(tvOS)
|
||
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
||
// miserable and the inline field chrome fights the focus system). Modes are
|
||
// preset pickers that push selection lists like the system Settings app.
|
||
tvBody
|
||
#elseif os(macOS)
|
||
macBody
|
||
#else
|
||
iosBody
|
||
#endif
|
||
}
|
||
|
||
// MARK: - macOS: tabbed preferences
|
||
|
||
#if os(macOS)
|
||
private var macBody: some View {
|
||
TabView {
|
||
Form {
|
||
streamModeSection
|
||
compositorSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.tabItem { Label("General", systemImage: "gearshape") }
|
||
|
||
Form {
|
||
presenterSection
|
||
hdrSection
|
||
windowSection
|
||
statisticsSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.tabItem { Label("Display", systemImage: "display") }
|
||
|
||
Form {
|
||
audioSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.onAppear {
|
||
outputDevices = AudioDevices.outputs()
|
||
inputDevices = AudioDevices.inputs()
|
||
micChannelCount = AudioDevices.inputChannelCount(forUID: micUID)
|
||
}
|
||
.onChange(of: micUID) { _, newUID in
|
||
// A different mic → different channel count; drop a now-out-of-range pin to Auto.
|
||
micChannelCount = AudioDevices.inputChannelCount(forUID: newUID)
|
||
if micChannel > micChannelCount { micChannel = 0 }
|
||
}
|
||
.tabItem { Label("Audio", systemImage: "speaker.wave.2") }
|
||
|
||
Form {
|
||
controllersSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.onAppear {
|
||
gamepads.refresh()
|
||
gamepads.startDiscovery()
|
||
}
|
||
.onDisappear { gamepads.stopDiscovery() }
|
||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||
|
||
Form {
|
||
experimentalSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||
|
||
AcknowledgementsView()
|
||
.tabItem { Label("About", systemImage: "info.circle") }
|
||
}
|
||
.frame(width: 480, height: 460)
|
||
}
|
||
#endif
|
||
|
||
// MARK: - iOS / iPadOS: adaptive split view
|
||
|
||
#if os(iOS)
|
||
private var iosBody: some View {
|
||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||
List(selection: $settingsSelection) {
|
||
ForEach(SettingsCategory.allCases) { category in
|
||
// On iPhone the split view collapses to a push list, but a selection List
|
||
// draws no disclosure indicator of its own — add one in compact width for the
|
||
// expected drill-in affordance. On iPad the selected row highlights instead, so
|
||
// the chevron is omitted there.
|
||
HStack {
|
||
Label(category.title, systemImage: category.symbol)
|
||
if horizontalSizeClass == .compact {
|
||
Spacer()
|
||
Image(systemName: "chevron.forward")
|
||
.font(.footnote.weight(.semibold))
|
||
.foregroundStyle(.tertiary)
|
||
// Purely a drill-in affordance — the row's button trait already
|
||
// conveys "opens"; keep it out of the VoiceOver announcement.
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
.tag(category)
|
||
}
|
||
}
|
||
.navigationTitle("Settings")
|
||
.toolbar {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("Done") { dismiss() }
|
||
}
|
||
}
|
||
} detail: {
|
||
// NavigationSplitView hosts the detail in its own navigation context (its title bar),
|
||
// so no inner NavigationStack — that would double the bar on iPad. On iPhone the split
|
||
// view collapses to one stack and pushes this when a row is tapped. `?? .general` only
|
||
// backs the brief pre-selection window; the list never auto-pushes on a nil selection.
|
||
settingsDetail(settingsSelection ?? .general)
|
||
// Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the
|
||
// iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the
|
||
// sidebar is showing, its Done is the only one — so this stays hidden to avoid two.
|
||
.toolbar {
|
||
if horizontalSizeClass == .compact || columnVisibility == .detailOnly {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("Done") { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
if horizontalSizeClass == .regular, settingsSelection == nil {
|
||
settingsSelection = .general
|
||
}
|
||
gamepads.refresh()
|
||
gamepads.startDiscovery()
|
||
}
|
||
// A regular→regular launch sets the default above; this catches a compact→regular change
|
||
// (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in.
|
||
.onChange(of: horizontalSizeClass) { _, newValue in
|
||
if newValue == .regular, settingsSelection == nil {
|
||
settingsSelection = .general
|
||
}
|
||
}
|
||
.onDisappear { gamepads.stopDiscovery() }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func settingsDetail(_ category: SettingsCategory) -> some View {
|
||
switch category {
|
||
case .general:
|
||
Form {
|
||
streamModeSection
|
||
pointerSection
|
||
compositorSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.navigationTitle("General")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .display:
|
||
Form {
|
||
presenterSection
|
||
hdrSection
|
||
statisticsSection
|
||
}
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Display")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .audio:
|
||
Form { audioSection }
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Audio")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .controllers:
|
||
Form { controllersSection }
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Controllers")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .advanced:
|
||
Form { experimentalSection }
|
||
.formStyle(.grouped)
|
||
.navigationTitle("Advanced")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .about:
|
||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||
// the large title from the "Settings" sidebar root).
|
||
AcknowledgementsView()
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// MARK: - tvOS
|
||
|
||
#if os(tvOS)
|
||
private static let presets: [(label: String, tag: String)] = [
|
||
("720p @ 60", "1280x720x60"),
|
||
("1080p @ 60", "1920x1080x60"),
|
||
("4K @ 60", "3840x2160x60"),
|
||
]
|
||
|
||
private var modeTag: Binding<String> {
|
||
Binding(
|
||
get: { "\(width)x\(height)x\(hz)" },
|
||
set: { tag in
|
||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||
guard parts.count == 3 else { return }
|
||
width = parts[0]
|
||
height = parts[1]
|
||
hz = parts[2]
|
||
})
|
||
}
|
||
|
||
private var hudEnabledTag: Binding<String> {
|
||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||
}
|
||
|
||
private var hdrEnabledTag: Binding<String> {
|
||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||
}
|
||
|
||
private var tvBody: some View {
|
||
let currentTag = "\(width)x\(height)x\(hz)"
|
||
let bounds = UIScreen.main.nativeBounds
|
||
let nativeTag = "\(Int(max(bounds.width, bounds.height)))x"
|
||
+ "\(Int(min(bounds.width, bounds.height)))x\(UIScreen.main.maximumFramesPerSecond)"
|
||
var options = Self.presets
|
||
if !options.contains(where: { $0.tag == nativeTag }) {
|
||
options.insert(("This TV (native)", nativeTag), at: 0)
|
||
}
|
||
if !options.contains(where: { $0.tag == currentTag }) {
|
||
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
|
||
}
|
||
return ScrollView {
|
||
VStack(spacing: 16) {
|
||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||
TVSelectionRow(
|
||
title: "Bitrate",
|
||
options: SettingsOptions.bitrateOptions(current: bitrateKbps),
|
||
selection: $bitrateKbps)
|
||
TVSelectionRow(
|
||
title: "Audio channels",
|
||
options: SettingsOptions.audioChannels,
|
||
selection: $audioChannels)
|
||
if bitrateKbps > 1_000_000 {
|
||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||
.font(.geist(12, relativeTo: .caption))
|
||
.foregroundStyle(.orange)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
TVSelectionRow(
|
||
title: "Compositor", options: SettingsOptions.compositors,
|
||
selection: $compositor)
|
||
#if DEBUG
|
||
TVSelectionRow(
|
||
title: "Presenter (debug)",
|
||
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||
selection: $presenter)
|
||
#endif
|
||
TVSelectionRow(
|
||
title: "10-bit HDR",
|
||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||
Text("The host creates a virtual output at exactly this mode — native "
|
||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||
+ "is honored only if available on the host.")
|
||
.font(.geist(12, relativeTo: .caption))
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.top, 8)
|
||
TVSelectionRow(
|
||
title: "Statistics overlay",
|
||
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag)
|
||
TVSelectionRow(
|
||
title: "Statistics position", options: SettingsOptions.hudPlacements,
|
||
selection: $hudPlacement)
|
||
ForEach(gamepads.controllers) { controller in
|
||
controllerRow(controller)
|
||
.padding(.horizontal, 24)
|
||
}
|
||
TVSelectionRow(
|
||
title: "Use controller", options: controllerOptions,
|
||
selection: $gamepads.preferredID)
|
||
TVSelectionRow(
|
||
title: "Controller type", options: SettingsOptions.padTypes,
|
||
selection: $gamepadType)
|
||
Text(Self.controllersFooter)
|
||
.font(.geist(12, relativeTo: .caption))
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.top, 8)
|
||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||
.padding(.top, 8)
|
||
}
|
||
.frame(maxWidth: 1000)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(60)
|
||
}
|
||
.navigationTitle("Settings")
|
||
.onAppear {
|
||
gamepads.refresh()
|
||
gamepads.startDiscovery()
|
||
}
|
||
.onDisappear { gamepads.stopDiscovery() }
|
||
}
|
||
#endif
|
||
}
|