diff --git a/clients/apple/README.md b/clients/apple/README.md index 312d836..e7c6b5e 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -91,7 +91,14 @@ What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3): the host burst probe filler over the real data plane (up to the host's 3 Gbps probe ceiling for 2 s, roadmap §9), shows measured goodput · loss · a recommended bitrate (≈70% of measured), and applies - it in one tap. + it in one tap. The streaming **statistics overlay** can be turned off and moved to any + corner (Settings → Display → Statistics, `DefaultsKey.hudEnabled`/`hudPlacement`), and + toggled live with **⌘⇧S** — a Scene-level **"Stream" menu** (`StreamCommands`) that also + carries **Disconnect ⌘D**, so disconnect survives the HUD being hidden (on iOS a small + exit chip appears instead; on tvOS the Siri-Remote Menu button still disconnects). The + macOS Settings window is a **tabbed preferences pane** (General / Display / Audio / + Controllers / Advanced) — the sections are shared with the iOS single-Form layout and the + tvOS pushed-picker layout, defined once each. - **Tests** (`swift test`): byte-level Annex-B units; a real-codec round trip (VTCompressionSession-encoded HEVC rebuilt as the host's wire shape → `AnnexB` → VTDecompressionSession → pixels); table-driven DualSense trigger-effect parsing diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 74ddeb0..05edce1 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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 } } } diff --git a/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift b/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift index 00759a9..362469a 100644 --- a/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift +++ b/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift @@ -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() diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 47cc2bb..60d450c 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -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 { + 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 } diff --git a/clients/apple/Sources/PunktfunkClient/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/StreamCommands.swift new file mode 100644 index 0000000..d27db62 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/StreamCommands.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift index ef7541b..6a8dcca 100644 --- a/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift index 96774cb..88b5733 100644 --- a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift @@ -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" }