feat(apple): tabbed macOS Settings + stats-overlay placement/toggle + Stream menu
ci / rust (push) Failing after 42s
apple / swift (push) Successful in 54s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 32s
android / android (push) Successful in 1m47s
ci / bench (push) Successful in 1m35s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m21s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m27s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m28s
docker / deploy-docs (push) Successful in 20s
ci / rust (push) Failing after 42s
apple / swift (push) Successful in 54s
ci / web (push) Successful in 29s
ci / docs-site (push) Successful in 32s
android / android (push) Successful in 1m47s
ci / bench (push) Successful in 1m35s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m21s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m27s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m28s
docker / deploy-docs (push) Successful in 20s
The macOS Settings window had outgrown one scrolling pane — split it into a tabbed preferences window (General / Display / Audio / Controllers / Advanced). Each settings group is now a shared @ViewBuilder section, so iOS keeps its single grouped Form and tvOS its pushed-picker layout, each defined once. No setting moved or dropped. New statistics-overlay controls (Settings → Display → Statistics): a show/hide toggle (DefaultsKey.hudEnabled) and a corner picker (HUDPlacement / DefaultsKey.hudPlacement) — the HUD moves to the chosen corner and aligns its text to that edge. A Scene-level "Stream" menu (StreamCommands) carries Show/Hide Statistics (⌘⇧S) and Disconnect (⌘D). Disconnect moved off the HUD button into the menu so it survives the overlay being hidden, wired via .focusedSceneValue. On iOS a material-backed exit chip appears when the HUD is hidden (touch users have no menu/⌘D); tvOS disconnect is unchanged (Siri-Remote Menu button). Builds on macOS/iOS/tvOS; swift test green. Adversarially reviewed (8 findings refuted, 2 minor — the iOS exit-chip contrast fix is included here). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,8 @@ struct ContentView: View {
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@State private var showAddHost = false
|
||||
@State private var pairingTarget: StoredHost?
|
||||
@State private var speedTestTarget: StoredHost?
|
||||
@@ -59,6 +61,13 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
|
||||
// Expose the session to the Scene-level Stream menu (Disconnect ⌘D works even when
|
||||
// the HUD is hidden). tvOS has no such menu.
|
||||
#if !os(tvOS)
|
||||
.focusedSceneValue(\.sessionFocus, SessionFocus(
|
||||
isStreaming: model.connection != nil,
|
||||
disconnect: { model.disconnect() }))
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
|
||||
// windowed on the host list — so the picker isn't forced fullscreen. Opt-out in Settings.
|
||||
@@ -155,7 +164,8 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func stream(captureEnabled: Bool) -> some View {
|
||||
Group {
|
||||
let placement = HUDPlacement(rawValue: hudPlacement) ?? .topTrailing
|
||||
return Group {
|
||||
if let conn = model.connection {
|
||||
StreamView(
|
||||
connection: conn,
|
||||
@@ -172,9 +182,30 @@ struct ContentView: View {
|
||||
},
|
||||
presentMeter: model.presentLatency
|
||||
)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if captureEnabled { StreamHUDView(model: model, connection: conn) }
|
||||
.overlay(alignment: placement.alignment) {
|
||||
if captureEnabled && hudEnabled {
|
||||
StreamHUDView(model: model, connection: conn, placement: placement)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button)
|
||||
// is hidden, keep a minimal always-reachable exit in a corner. It rides a
|
||||
// material disc (like the HUD) so the glyph stays legible over a bright frame
|
||||
// — this is the sole touch disconnect path when stats are off.
|
||||
.overlay(alignment: .topLeading) {
|
||||
if captureEnabled && !hudEnabled {
|
||||
Button { model.disconnect() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(12)
|
||||
.accessibilityLabel("Disconnect")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ struct PunktfunkClientApp: App {
|
||||
WindowGroup("Punktfunkempfänger") {
|
||||
ContentView()
|
||||
}
|
||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||
#if !os(tvOS)
|
||||
.commands { StreamCommands() }
|
||||
#endif
|
||||
#if os(macOS)
|
||||
Settings {
|
||||
SettingsView()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// App settings (⌘,): the stream mode, the host compositor, and controllers. The host
|
||||
// creates a native virtual output at exactly this size/refresh — there is no scaling
|
||||
// anywhere in the pipeline.
|
||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||
// there is no scaling anywhere in the pipeline.
|
||||
//
|
||||
// Navigation differs per platform: macOS uses a tabbed preferences window (the sections had
|
||||
// outgrown one scrolling pane); iOS uses a single grouped Form; tvOS uses a focus-native
|
||||
// pushed-picker layout. The individual sections (`streamModeSection`, `audioSection`, …) are
|
||||
// shared across all three so a setting is defined exactly once.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -21,6 +25,8 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||
@@ -32,14 +38,91 @@ struct SettingsView: View {
|
||||
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). The mode is
|
||||
// a preset picker; pickers push selection lists like the system Settings app.
|
||||
// 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
|
||||
sharedBody
|
||||
iosBody
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - macOS: tabbed preferences
|
||||
|
||||
#if os(macOS)
|
||||
private var macBody: some View {
|
||||
TabView {
|
||||
Form {
|
||||
streamModeSection
|
||||
compositorSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
|
||||
Form {
|
||||
presenterSection
|
||||
windowSection
|
||||
statisticsSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Display", systemImage: "display") }
|
||||
|
||||
Form {
|
||||
audioSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
outputDevices = AudioDevices.outputs()
|
||||
inputDevices = AudioDevices.inputs()
|
||||
}
|
||||
.tabItem { Label("Audio", systemImage: "speaker.wave.2") }
|
||||
|
||||
Form {
|
||||
controllersSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||||
|
||||
Form {
|
||||
experimentalSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
|
||||
}
|
||||
.frame(width: 480, height: 460)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - iOS: one grouped Form
|
||||
|
||||
#if os(iOS)
|
||||
private var iosBody: some View {
|
||||
Form {
|
||||
streamModeSection
|
||||
audioSection
|
||||
compositorSection
|
||||
presenterSection
|
||||
statisticsSection
|
||||
experimentalSection
|
||||
controllersSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - tvOS
|
||||
|
||||
#if os(tvOS)
|
||||
private static let presets: [(label: String, tag: String)] = [
|
||||
("720p @ 60", "1280x720x60"),
|
||||
@@ -59,6 +142,10 @@ struct SettingsView: View {
|
||||
})
|
||||
}
|
||||
|
||||
private var hudEnabledTag: Binding<String> {
|
||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var tvBody: some View {
|
||||
let currentTag = "\(width)x\(height)x\(hz)"
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
@@ -102,6 +189,12 @@ struct SettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
TVSelectionRow(
|
||||
title: "Statistics overlay",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag)
|
||||
TVSelectionRow(
|
||||
title: "Statistics position", options: Self.placementOptions,
|
||||
selection: $hudPlacement)
|
||||
ForEach(gamepads.controllers) { controller in
|
||||
controllerRow(controller)
|
||||
.padding(.horizontal, 24)
|
||||
@@ -130,6 +223,203 @@ struct SettingsView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Sections (shared)
|
||||
|
||||
@ViewBuilder private var streamModeSection: some View {
|
||||
Section {
|
||||
HStack {
|
||||
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
||||
Text("×")
|
||||
TextField("", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
}
|
||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||
LabeledContent("") {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
}
|
||||
#if !os(tvOS)
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
}
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .trailing)
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text("Stream mode")
|
||||
} footer: {
|
||||
Text("The host creates a virtual output at exactly this mode — "
|
||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var audioSection: some View {
|
||||
Section {
|
||||
#if os(macOS)
|
||||
Picker("Speaker", selection: $speakerUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(outputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
}
|
||||
if !speakerUID.isEmpty,
|
||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||
Text("Unavailable device").tag(speakerUID)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||
#if os(macOS)
|
||||
Picker("Microphone", selection: $micUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(inputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
}
|
||||
if !micUID.isEmpty,
|
||||
!inputDevices.contains(where: { $0.uid == micUID }) {
|
||||
Text("Unavailable device").tag(micUID)
|
||||
}
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
#endif
|
||||
} header: {
|
||||
Text("Audio")
|
||||
} footer: {
|
||||
Text("Host audio plays through the speaker; the microphone feeds the "
|
||||
+ "host's virtual mic. System default follows macOS device changes. "
|
||||
+ "Applies from the next session.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var compositorSection: some View {
|
||||
Section {
|
||||
Picker("Compositor", selection: $compositor) {
|
||||
Text("Automatic").tag(0)
|
||||
Text("KWin (KDE Plasma)").tag(1)
|
||||
Text("wlroots (Sway / Hyprland)").tag(2)
|
||||
Text("Mutter (GNOME)").tag(3)
|
||||
Text("gamescope").tag(4)
|
||||
}
|
||||
} header: {
|
||||
Text("Host compositor")
|
||||
} footer: {
|
||||
Text("Which compositor drives the virtual output on the host. A specific "
|
||||
+ "choice is honored only if that backend is available there — "
|
||||
+ "otherwise the host falls back to auto-detection.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var windowSection: some View {
|
||||
#if os(macOS)
|
||||
Section {
|
||||
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
||||
} header: {
|
||||
Text("Window")
|
||||
} footer: {
|
||||
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||
+ "list, so only the stream is fullscreen — not the picker.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var presenterSection: some View {
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
Text("Stage 1 (default)").tag("stage1")
|
||||
Text("Stage 2 (experimental)").tag("stage2")
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter")
|
||||
} footer: {
|
||||
Text("Stage 1 feeds compressed video to the system display layer (known-good). "
|
||||
+ "Stage 2 decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD "
|
||||
+ "and shortens the present tail. Applies from the next session.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var statisticsSection: some View {
|
||||
Section {
|
||||
Toggle("Show statistics overlay", isOn: $hudEnabled)
|
||||
Picker("Position", selection: $hudPlacement) {
|
||||
ForEach(HUDPlacement.allCases) { placement in
|
||||
Text(placement.label).tag(placement.rawValue)
|
||||
}
|
||||
}
|
||||
.disabled(!hudEnabled)
|
||||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text(Self.statisticsFooter)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var experimentalSection: some View {
|
||||
Section {
|
||||
Toggle("Show game library", isOn: $libraryEnabled)
|
||||
} header: {
|
||||
Text("Experimental")
|
||||
} footer: {
|
||||
Text("Adds a “Browse Library…” action to each host that lists its games "
|
||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
||||
+ "The host must expose that API on the LAN with a token "
|
||||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var controllersSection: some View {
|
||||
Section {
|
||||
if gamepads.controllers.isEmpty {
|
||||
Text("No controllers detected")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(gamepads.controllers) { controller in
|
||||
controllerRow(controller)
|
||||
}
|
||||
}
|
||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||
ForEach(controllerOptions, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
Picker("Controller type", selection: $gamepadType) {
|
||||
ForEach(Self.padTypes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
Text(Self.controllersFooter)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bitrate
|
||||
|
||||
/// Slider domain, log-scale: the useful range spans three orders of magnitude
|
||||
@@ -199,8 +489,23 @@ struct SettingsView: View {
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
private static let placementOptions: [(label: String, tag: String)] =
|
||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||
#endif
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
private static var statisticsFooter: String {
|
||||
let base = "The overlay shows resolution, frame rate, throughput and latency while "
|
||||
+ "streaming, in the chosen corner."
|
||||
#if os(macOS) || os(iOS)
|
||||
return base + " Toggle it any time with ⌘⇧S."
|
||||
#else
|
||||
return base
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Controllers
|
||||
|
||||
private static let padTypes: [(label: String, tag: Int)] = [
|
||||
@@ -274,190 +579,6 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var sharedBody: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
||||
Text("×")
|
||||
TextField("", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
}
|
||||
TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never))
|
||||
LabeledContent("") {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
}
|
||||
// (sharedBody is unused on tvOS — its body still compiles there, and
|
||||
// Slider doesn't exist on tvOS; the tv path has its own preset picker.)
|
||||
#if !os(tvOS)
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: bitrateSlider, in: 0...1) {
|
||||
Text("Bitrate")
|
||||
}
|
||||
Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 76, alignment: .trailing)
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} header: {
|
||||
Text("Stream mode")
|
||||
} footer: {
|
||||
Text("The host creates a virtual output at exactly this mode — "
|
||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
Section {
|
||||
#if os(macOS)
|
||||
Picker("Speaker", selection: $speakerUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(outputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
}
|
||||
if !speakerUID.isEmpty,
|
||||
!outputDevices.contains(where: { $0.uid == speakerUID }) {
|
||||
Text("Unavailable device").tag(speakerUID)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Send microphone to the host", isOn: $micEnabled)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
Picker("Microphone", selection: $micUID) {
|
||||
Text("System default").tag("")
|
||||
ForEach(inputDevices) { device in
|
||||
Text(device.name).tag(device.uid)
|
||||
}
|
||||
if !micUID.isEmpty,
|
||||
!inputDevices.contains(where: { $0.uid == micUID }) {
|
||||
Text("Unavailable device").tag(micUID)
|
||||
}
|
||||
}
|
||||
.disabled(!micEnabled)
|
||||
#endif
|
||||
} header: {
|
||||
Text("Audio")
|
||||
} footer: {
|
||||
Text("Host audio plays through the speaker; the microphone feeds the "
|
||||
+ "host's virtual mic. System default follows macOS device changes. "
|
||||
+ "Applies from the next session.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
Section {
|
||||
Picker("Compositor", selection: $compositor) {
|
||||
Text("Automatic").tag(0)
|
||||
Text("KWin (KDE Plasma)").tag(1)
|
||||
Text("wlroots (Sway / Hyprland)").tag(2)
|
||||
Text("Mutter (GNOME)").tag(3)
|
||||
Text("gamescope").tag(4)
|
||||
}
|
||||
} header: {
|
||||
Text("Host compositor")
|
||||
} footer: {
|
||||
Text("Which compositor drives the virtual output on the host. A specific "
|
||||
+ "choice is honored only if that backend is available there — "
|
||||
+ "otherwise the host falls back to auto-detection.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#if os(macOS)
|
||||
Section {
|
||||
Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming)
|
||||
} header: {
|
||||
Text("Window")
|
||||
} footer: {
|
||||
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||
+ "list, so only the stream is fullscreen — not the picker.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// The client-side cursor picker is hidden while the feature is disabled (gamescope's
|
||||
// input is relative-only, so absolute cursor positioning traps input — see StreamView).
|
||||
// Restore this Section when per-compositor gating / a synthetic cursor lands.
|
||||
#endif
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
Text("Stage 1 (default)").tag("stage1")
|
||||
Text("Stage 2 (experimental)").tag("stage2")
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter")
|
||||
} footer: {
|
||||
Text("Stage 1 feeds compressed video to the system display layer (known-good). "
|
||||
+ "Stage 2 decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD "
|
||||
+ "and shortens the present tail. Applies from the next session.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section {
|
||||
Toggle("Show game library", isOn: $libraryEnabled)
|
||||
} header: {
|
||||
Text("Experimental")
|
||||
} footer: {
|
||||
Text("Adds a “Browse Library…” action to each host that lists its games "
|
||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
||||
+ "The host must expose that API on the LAN with a token "
|
||||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section {
|
||||
if gamepads.controllers.isEmpty {
|
||||
Text("No controllers detected")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(gamepads.controllers) { controller in
|
||||
controllerRow(controller)
|
||||
}
|
||||
}
|
||||
Picker("Use controller", selection: $gamepads.preferredID) {
|
||||
ForEach(controllerOptions, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
Picker("Controller type", selection: $gamepadType) {
|
||||
ForEach(Self.padTypes, id: \.tag) { option in
|
||||
Text(option.label).tag(option.tag)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
Text(Self.controllersFooter)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
#if os(macOS)
|
||||
.frame(width: 380)
|
||||
.fixedSize()
|
||||
.onAppear {
|
||||
outputDevices = AudioDevices.outputs()
|
||||
inputDevices = AudioDevices.inputs()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func fillFromMainScreen() {
|
||||
#if os(macOS)
|
||||
guard let screen = NSScreen.main else { return }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at
|
||||
// the Scene level so they keep working when the HUD overlay is hidden — in particular ⌘D
|
||||
// disconnect, which used to be reachable only via the HUD's button. The toggle just flips the
|
||||
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts.
|
||||
//
|
||||
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
|
||||
// Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is
|
||||
// non-tvOS only.
|
||||
|
||||
#if !os(tvOS)
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// The live session's menu-reachable actions, published by ContentView via
|
||||
/// `.focusedSceneValue` so the Scene-level commands can drive it.
|
||||
struct SessionFocus {
|
||||
var isStreaming: Bool
|
||||
var disconnect: () -> Void
|
||||
}
|
||||
|
||||
private struct SessionFocusKey: FocusedValueKey {
|
||||
typealias Value = SessionFocus
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var sessionFocus: SessionFocus? {
|
||||
get { self[SessionFocusKey.self] }
|
||||
set { self[SessionFocusKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamCommands: Commands {
|
||||
@FocusedValue(\.sessionFocus) private var session
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
|
||||
var body: some Commands {
|
||||
CommandMenu("Stream") {
|
||||
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") {
|
||||
hudEnabled.toggle()
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: [.command, .shift])
|
||||
Divider()
|
||||
Button("Disconnect") { session?.disconnect() }
|
||||
.keyboardShortcut("d", modifiers: .command)
|
||||
.disabled(session?.isStreaming != true)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -4,12 +4,44 @@
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Which corner the HUD overlay occupies (persisted as `DefaultsKey.hudPlacement`). The raw
|
||||
/// values are stable on disk — rename the cases freely, never the strings.
|
||||
enum HUDPlacement: String, CaseIterable, Identifiable {
|
||||
case topLeading, topTrailing, bottomLeading, bottomTrailing
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// SwiftUI overlay alignment for `.overlay(alignment:)`.
|
||||
var alignment: Alignment {
|
||||
switch self {
|
||||
case .topLeading: return .topLeading
|
||||
case .topTrailing: return .topTrailing
|
||||
case .bottomLeading: return .bottomLeading
|
||||
case .bottomTrailing: return .bottomTrailing
|
||||
}
|
||||
}
|
||||
|
||||
/// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward.
|
||||
var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing }
|
||||
|
||||
/// User-facing corner label.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .topLeading: return "Top Left"
|
||||
case .topTrailing: return "Top Right"
|
||||
case .bottomLeading: return "Bottom Left"
|
||||
case .bottomTrailing: return "Bottom Right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamHUDView: View {
|
||||
@ObservedObject var model: SessionModel
|
||||
let connection: PunktfunkConnection
|
||||
var placement: HUDPlacement = .topTrailing
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
@@ -60,9 +92,10 @@ struct StreamHUDView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
#else
|
||||
// ⌘D lives on the app's Stream menu (so it still works when the HUD is hidden);
|
||||
// this button is the in-overlay, click-to-disconnect affordance.
|
||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||
.font(.caption)
|
||||
.keyboardShortcut("d", modifiers: .command)
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
@@ -26,4 +26,10 @@ public enum DefaultsKey {
|
||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
|
||||
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle
|
||||
/// while streaming with ⌘⇧S (macOS / hardware keyboard).
|
||||
public static let hudEnabled = "punktfunk.hudEnabled"
|
||||
/// Which corner the statistics overlay sits in — a `HUDPlacement` raw value
|
||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||
public static let hudPlacement = "punktfunk.hudPlacement"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user