diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index e1264200..c28d6add 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -315,7 +315,15 @@ struct ContentView: View { clipboardAvailable: model.connection?.hostSupportsClipboard == true, clipboardOn: model.clipboardEnabled, toggleClipboard: { model.toggleClipboardSync() }, + micAvailable: model.micAvailable, + micMuted: model.micMuted, + toggleMicMute: { model.toggleMicMute() }, disconnect: { model.disconnect() })) + // ⌃⌥⇧A fired while input was CAPTURED (InputCapture's chord path posts it — the menu's + // identical equivalent can't reach a captured stream). Same toggle either way. + .onReceive(NotificationCenter.default.publisher(for: .punktfunkToggleMicMute)) { _ in + model.toggleMicMute() + } #endif #if os(macOS) // Fullscreen only while a session is up (incl. the trust prompt over the blurred stream), @@ -724,31 +732,48 @@ struct ContentView: View { } .animation(.smooth(duration: 0.28), value: statsVerbosity) } - #if os(macOS) || os(tvOS) - // The start-of-stream shortcut banner (Windows-client parity): the platform's - // reserved controls on a glass pill, bottom-centre, for the first 6 seconds of - // every session — independent of the stats HUD, so the keys are discoverable - // even with statistics off. The banner's own task drops it (cancelled cleanly - // if the session view goes away first). On tvOS it carries the ONLY exits — - // Menu/B is swallowed during a session (the `.onExitCommand {}` in the tvOS - // session branch), so the hold gestures must be told to the user. + // The bottom-centre stack: the muted-microphone badge over the start-of-stream + // shortcut banner. ONE overlay for both, so the two can never land on top of each + // other in the seconds where they overlap. .overlay(alignment: .bottom) { - if captureEnabled && showShortcutHint { - Text(Self.shortcutHintText) - .font(.geist(Self.shortcutHintFont, relativeTo: .caption)) - .foregroundStyle(.secondary) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .glassBackground(Capsule()) - .padding(.bottom, 24) - .transition(.opacity) - .task { - try? await Task.sleep(for: .seconds(6)) - withAnimation(.easeOut(duration: 0.6)) { showShortcutHint = false } - } + VStack(spacing: 8) { + #if !os(tvOS) + // Shown for as long as the mic is muted, at every stats tier and with the + // overlay off — see MicMutedBadge. tvOS has no microphone to mute. + if captureEnabled && model.micMuted { + MicMutedBadge { model.setMicMuted(false) } + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + #endif + #if os(macOS) || os(tvOS) + // The start-of-stream shortcut banner (Windows-client parity): the + // platform's reserved controls on a glass pill for the first 6 seconds of + // every session — independent of the stats HUD, so the keys are + // discoverable even with statistics off. The banner's own task drops it + // (cancelled cleanly if the session view goes away first). On tvOS it + // carries the ONLY exits — Menu/B is swallowed during a session (the + // `.onExitCommand {}` in the tvOS session branch), so the hold gestures + // must be told to the user. + if captureEnabled && showShortcutHint { + Text(shortcutHintText) + .font(.geist(Self.shortcutHintFont, relativeTo: .caption)) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .glassBackground(Capsule()) + .transition(.opacity) + .task { + try? await Task.sleep(for: .seconds(6)) + withAnimation(.easeOut(duration: 0.6)) { + showShortcutHint = false + } + } + } + #endif } + .padding(.bottom, 24) + .animation(.easeOut(duration: 0.2), value: model.micMuted) } - #endif #if os(iOS) // Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on // screen — the overlay off, or the compact pill (which carries no button) — @@ -766,21 +791,24 @@ struct ContentView: View { .overlay(alignment: .topLeading) { if captureEnabled, statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) { - Button { model.disconnect() } label: { - Image(systemName: "xmark") - .font(.headline.weight(.semibold)) - .frame(width: 36, height: 36) - // Floating glass disc over the frame (26+, material fallback). - // interactive: the disc IS the tap target, so the glass reacts - // to press. - .glassBackground(Circle(), interactive: true) - // Match the hit region to the visible disc so every tap also - // triggers the interactive-glass press highlight. - .contentShape(Circle()) + HStack(spacing: 10) { + Button { model.disconnect() } label: { touchDisc("xmark") } + .buttonStyle(.plain) + .accessibilityLabel("Disconnect") + // The mic toggle rides the same discs, for the same reason: in these + // tiers the HUD carries no buttons (compact is a stat pill, off is + // nothing), so this is a touch-only user's ONLY way to mute. Absent — + // not greyed — when the session sends no microphone at all. + if model.micAvailable { + Button { model.toggleMicMute() } label: { + touchDisc(model.micMuted ? "mic.slash.fill" : "mic.fill") + } + .buttonStyle(.plain) + .accessibilityLabel( + model.micMuted ? "Unmute microphone" : "Mute microphone") + } } - .buttonStyle(.plain) .padding(12) - .accessibilityLabel("Disconnect") .transition(.opacity) .task { guard statsVerbosity == .off else { return } @@ -794,14 +822,34 @@ struct ContentView: View { } } + #if os(iOS) + /// One touch-control disc: an SF Symbol on a floating glass disc over the frame (26+, + /// material fallback), sized as a comfortable tap target. `interactive`: the disc IS the tap + /// target, so the glass reacts to press, and the hit region is matched to the visible disc so + /// every tap triggers that press highlight. + private func touchDisc(_ symbol: String) -> some View { + Image(systemName: symbol) + .font(.headline.weight(.semibold)) + .frame(width: 36, height: 36) + .glassBackground(Circle(), interactive: true) + .contentShape(Circle()) + } + #endif + #if os(macOS) - private static let shortcutHintText = - "Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats" + /// The reserved combos, told once per session. The mute segment appears only when the session + /// actually sends a microphone — teaching a shortcut for a mic that isn't on would be a lie. + private var shortcutHintText: String { + let base = + "Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats" + return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base + } private static let shortcutHintFont: CGFloat = 12 #elseif os(tvOS) - private static let shortcutHintText = + private var shortcutHintText: String { "Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect" + " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks" + } private static let shortcutHintFont: CGFloat = 22 // read from the couch #endif diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index e3c9a525..f1dab9ca 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -1,6 +1,9 @@ // Session state for the app shell: owns the connection, the input capture, the trust // handshake phase, and the pump-thread → main-actor stats relay. +// AVFoundation: AVCaptureDevice.authorizationStatus (the mic TCC grant behind `micAvailable`) +// and, on tvOS, AVPlayer.eligibleForHDRPlayback (the TV-capability HDR gate). +import AVFoundation import Foundation import os import PunktfunkKit @@ -11,9 +14,6 @@ import SwiftUI #elseif canImport(UIKit) import UIKit #endif -#if os(tvOS) - import AVFoundation // AVPlayer.eligibleForHDRPlayback — the TV-capability HDR gate -#endif /// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the /// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral @@ -137,6 +137,14 @@ final class SessionModel: ObservableObject { /// Mirrors StreamView's capture state (it owns the input capture; this drives the /// HUD's "click to capture" / "⌘⎋ releases" hint). @Published var mouseCaptured = false + /// The USER's in-stream mic mute (the HUD button, the Stream menu's ⌃⌥⇧A, the captured-state + /// chord, the iOS mic disc) — session state, deliberately NOT persisted: a mute is for the + /// people in the room right now, so every new session starts live if the mic is on at all. + /// One of the two inputs to the effective mute; `isBackgrounded` is the other, and + /// `applyMicMute` composes them — a user mute survives a trip through the background, and the + /// background's privacy mute never clears the user's choice. Local and instant: it gates + /// capture on this device, nothing is sent to the host. + @Published private(set) var micMuted = false /// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the /// instant a Match-window resize starts steering toward a new size until a frame at that size /// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay @@ -440,7 +448,7 @@ final class SessionModel: ObservableObject { guard phase == .streaming, let conn = connection, !isBackgrounded else { return } isBackgrounded = true conn.setVideoDropped(true) - audio?.setMicMuted(true) + applyMicMute() // now muted for privacy — on top of the user's own mute, not instead of it // Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast, // exactly like today's network-drop path. min 1 minute guards a nonsense setting. let minutes = max(1, timeoutMinutes) @@ -465,13 +473,57 @@ final class SessionModel: ObservableObject { backgroundDeadline = nil backgroundTimer?.cancel() backgroundTimer = nil - audio?.setMicMuted(false) + applyMicMute() // back to the user's own choice — which may well still be "muted" if let conn = connection { conn.setVideoDropped(false) conn.requestKeyframe() } } + // MARK: - Microphone mute (in-stream, per session) + + /// Whether this session has a mic uplink there is any point in muting: the mic must be on in + /// the session's RESOLVED settings (a profile can turn it on or off), the platform must have + /// an app-accessible input at all, and the OS must not have refused us one. Drives whether the + /// mute control is offered — a live-looking mute button over a session that sends no + /// microphone would be a lie. Same three conditions `SessionAudio` starts an uplink on + /// (`.notDetermined` counts: the prompt is pending and a grant starts the uplink mid-session). + var micAvailable: Bool { + #if os(tvOS) + return false // no app-accessible microphone — SessionAudio never opens an uplink either + #else + guard settings.micEnabled else { return false } + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized, .notDetermined: return true + default: return false // denied / restricted — there is no uplink to mute + } + #endif + } + + /// Flip the user's mute. The in-stream surfaces (HUD button, Stream menu, ⌃⌥⇧A while + /// captured, the iOS mic disc) all land here. + func toggleMicMute() { + setMicMuted(!micMuted) + } + + /// Set the user's mute directly (the badge's tap-to-unmute). Ignored when the session has no + /// microphone, so a stale surface can't leave a phantom "muted" badge over a session that was + /// never sending anything. + func setMicMuted(_ muted: Bool) { + guard micAvailable, micMuted != muted else { return } + micMuted = muted + applyMicMute() + } + + /// Push the EFFECTIVE mute — the user's choice OR the background keep-alive's privacy mute — + /// onto the audio engine. The two reasons are composed here and nowhere else: whichever one + /// changed, the other still holds, so returning from the background can't un-mute a user who + /// muted mid-stream, and a user unmuting while backgrounded (Live Activity, another window) + /// doesn't open the mic behind their back. + private func applyMicMute() { + audio?.setMicMuted(micMuted || isBackgrounded) + } + /// Follow a live stats-overlay cycle (⌃⌥⇧S, the three-finger tap, the Stream menu). Those /// surfaces write the GLOBAL setting as they always have; this moves the session's own tier /// with it, so cycling still works in a session a profile put on a different tier. @@ -509,6 +561,9 @@ final class SessionModel: ObservableObject { backgroundTimer = nil isBackgrounded = false backgroundDeadline = nil + // The mic mute is per-session and never persisted: the next stream starts live (if the + // mic is enabled), rather than silently carrying a mute nobody remembers making. + micMuted = false let audio = self.audio self.audio = nil // Gamepad capture is main-actor (releases held buttons on the wire while the diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift index a9085f1d..86f50069 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift @@ -1,7 +1,8 @@ // 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. The shortcuts are the // CROSS-CLIENT set every punktfunk client reserves — Ctrl+Alt+Shift+Q (release the captured -// mouse) / +D (disconnect) / +S (stats) — and the menu is their discoverable surface on macOS +// mouse) / +D (disconnect) / +S (stats), plus +A (mute the microphone), the Apple clients' +// addition to it — and the menu is their discoverable surface on macOS // (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While // input is CAPTURED these key equivalents never reach the menu (the stream view swallows // keys); InputCapture's monitor detects the same combos there and performs the same actions — @@ -27,6 +28,12 @@ struct SessionFocus { /// Clipboard sync is live (host-acked) — drives the item's Stop/Share title. var clipboardOn: Bool var toggleClipboard: () -> Void + /// The session has a mic uplink at all (its resolved `micEnabled`) — gates the mute item, so + /// it is never an enabled control over a session that sends no microphone. + var micAvailable: Bool + /// The user's mic mute is engaged — drives the item's Mute/Unmute title. + var micMuted: Bool + var toggleMicMute: () -> Void var disconnect: () -> Void } @@ -60,6 +67,17 @@ struct StreamCommands: Commands { } .keyboardShortcut("q", modifiers: [.control, .option, .shift]) .disabled(session?.isStreaming != true) + // Mic mute, local and instant (it gates capture on this device — the host is never + // asked). Per SESSION: it starts off every time, so this item is a live toggle, not a + // setting. Greyed when the session sends no microphone at all (Settings → mic off, or + // a profile that turns it off) rather than pretending there is something to mute. + // Captured, the combo is handled by InputCapture's chord path before menus see it; + // this item is the released-state path and the shortcut's documentation. + Button(session?.micMuted == true ? "Unmute Microphone" : "Mute Microphone") { + session?.toggleMicMute() + } + .keyboardShortcut("a", modifiers: [.control, .option, .shift]) + .disabled(session?.isStreaming != true || session?.micAvailable != true) #if os(macOS) // Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed // when the host doesn't advertise the cap (older host / operator policy off). diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index 67a21962..41606725 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -179,6 +179,18 @@ struct StreamHUDView: View { .foregroundStyle(.secondary) } #endif + // Mic mute — the in-stream toggle, on the same card as the other in-overlay action. + // Absent (not greyed) when the session sends no microphone: the HUD is a status card, + // and a dead control on it would read as "there is a mic, and it is on". The muted + // STATE is not this button's job — the badge over the stream says that at every tier + // and with the overlay off entirely. tvOS gets no control: no microphone, and a + // focusable one would steal the controller's A press from the host. + #if !os(tvOS) + if model.micAvailable { + Button(micButtonTitle) { model.toggleMicMute() } + .font(.geist(12, relativeTo: .caption)) + } + #endif // ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden) // and in InputCapture's monitor while captured; this button is the in-overlay, // click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a @@ -195,6 +207,19 @@ struct StreamHUDView: View { } } + #if !os(tvOS) + /// The mute button's wording. macOS names the chord, exactly as its Disconnect button does; + /// iOS/iPadOS spells the action out (the HUD's buttons there carry no shortcuts, even where a + /// hardware keyboard could fire one — the Stream menu is that keyboard's surface). + private var micButtonTitle: String { + #if os(macOS) + return model.micMuted ? "Unmute Mic (⌃⌥⇧A)" : "Mute Mic (⌃⌥⇧A)" + #else + return model.micMuted ? "Unmute Microphone" : "Mute Microphone" + #endif + } + #endif + // MARK: - Card metrics /// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the @@ -242,6 +267,42 @@ struct StreamHUDView: View { } } +#if !os(tvOS) +/// The muted-microphone badge — the mute STATE, as opposed to the buttons that flip it. It rides +/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user +/// may have cycled off, and which the compact tier reduces to a stat line): "am I muted?" is not a +/// statistic, and a mute you can't see is how people talk to nobody for a minute. Same glass +/// language as the HUD, sized like the start-of-stream banner it shares the bottom edge with. +/// +/// It is also a control: tapping it unmutes. That is the guaranteed way back for a touch user who +/// muted with the overlay off, and it costs the badge nothing (it is on screen either way). +struct MicMutedBadge: View { + let onUnmute: () -> Void + + var body: some View { + Button(action: onUnmute) { + HStack(spacing: 7) { + Image(systemName: "mic.slash.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.red) + Text("Microphone muted") + .font(.geist(12, .medium, relativeTo: .caption)) + .foregroundStyle(.white.opacity(0.9)) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + // interactive: the badge IS the tap target, so the glass reacts to press. + .glassBackground(Capsule(), interactive: true) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay + .accessibilityLabel("Microphone muted") + .accessibilityHint("Unmutes the microphone") + } +} +#endif + #if os(iOS) /// Device display geometry the overlay needs but UIKit doesn't expose publicly. enum DeviceMetrics { diff --git a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index a5495671..6ff85813 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -51,6 +51,12 @@ public final class SessionAudio { /// `playbackEngine`/`captureEngine` stay nil while this is set. private var combinedEngine: AVAudioEngine? private var drainStarted = false + /// The mute LATCH: the effective mute the owner last asked for (see `setMicMuted`). Held + /// because the uplink engine can appear LATER than the request — the mic permission prompt + /// is answered at the user's leisure, and the engine is built on the grant — so a mute set + /// in the meantime must be waiting for it. Applied by whichever start path wins the race. + /// Guarded by `stateLock`, like the engines it applies to. + private var micMuted = false /// The playback jitter ring — created by whichever engine starts playback first and KEPT /// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines, /// not the ring, so the drain thread never has to be re-pointed). Main-thread confined, @@ -259,19 +265,34 @@ public final class SessionAudio { } } - /// Background keep-alive: silence the mic uplink while backgrounded (privacy — no room audio - /// leaves the device) and restore it on return. Two-engine sessions pause/resume the capture - /// engine; a combined session instead mutes the voice processor's input (playback shares that - /// engine and must keep running, so the engine itself never pauses — the mute zeroes the mic - /// at the IO unit, and the tap encodes silence). A no-op when there's no uplink (playback-only - /// / tvOS / mic disabled). The audio SESSION stays active for background playback, so iOS may - /// keep showing the recording indicator until a full reconfigure — either path stops room - /// audio leaving the device, which is the privacy-relevant part. Main thread. + /// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting + /// mechanism: the owner composes its reasons — the user's in-stream mute and the background + /// keep-alive's privacy mute — into one effective state and passes that here, so neither can + /// clear the other (see `SessionModel.applyMicMute`). + /// + /// Two-engine sessions pause/resume the capture engine; a combined session instead mutes the + /// voice processor's input (playback shares that engine and must keep running, so the engine + /// itself never pauses — the mute zeroes the mic at the IO unit, and the tap encodes silence). + /// Local and instant either way: nothing is negotiated with the host, and the packets that do + /// leave carry silence. A no-op when there's no uplink (playback-only / tvOS / mic disabled), + /// except that the state is LATCHED for an uplink that starts later. The audio SESSION stays + /// active for background playback, so iOS may keep showing the recording indicator until a + /// full reconfigure — either path stops room audio leaving the device, which is the + /// privacy-relevant part. Main thread. public func setMicMuted(_ muted: Bool) { stateLock.lock() + micMuted = muted let capture = captureEngine let combined = combinedEngine stateLock.unlock() + apply(micMuted: muted, capture: capture, combined: combined) + } + + /// Push the latched mute onto whichever engine carries the uplink. Split out from + /// `setMicMuted` because the start paths call it too, with the engine they just started — + /// that's how a mute requested before the permission grant lands on the engine the grant + /// creates. Never resumes a stopped session's engine. + private func apply(micMuted muted: Bool, capture: AVAudioEngine?, combined: AVAudioEngine?) { if let combined { combined.inputNode.isVoiceProcessingInputMuted = muted return @@ -473,7 +494,9 @@ public final class SessionAudio { return } combinedEngine = engine + let muted = micMuted // latched before this engine existed (a mute during the prompt) stateLock.unlock() + apply(micMuted: muted, capture: nil, combined: engine) startDrain(into: ring) log.info("audio engines joined — voice processing (echo cancellation) active") } @@ -517,7 +540,9 @@ public final class SessionAudio { return } captureEngine = engine + let muted = micMuted // latched before this engine existed (a mute during the prompt) stateLock.unlock() + apply(micMuted: muted, capture: engine, combined: nil) log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))") } diff --git a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift index 7c1abf70..0434295c 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift @@ -128,11 +128,19 @@ public final class InputCapture { /// carries the same key equivalents for discoverability) can't see them, so the monitor is the /// captured-state delivery path; released, the events pass through and the menu handles them. /// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S cycles the stats - /// overlay tier (off → compact → normal → detailed). Main queue. + /// overlay tier (off → compact → normal → detailed). ⌃⌥⇧A (`onToggleMicMute`, below) rides + /// the same path. Main queue. public var onReleaseCapture: (() -> Void)? public var onDisconnect: (() -> Void)? public var onCycleStats: (() -> Void)? + /// Fired on ⌃⌥⇧A — mute/unmute the microphone uplink, the one in-stream control a captured + /// session can't otherwise reach (the HUD's button is behind a grabbed cursor). Same delivery + /// rule as the combos above: only WHILE FORWARDING, because that's when the menu's identical + /// key equivalent can't fire. ⌃⌥⇧M — the obvious letter — is long since the mouse-model flip + /// (cross-client), so A ("audio in") is the mic's. Main queue. + public var onToggleMicMute: (() -> Void)? + /// Fired on ⌃⌘F (macOS) — toggle the streaming window in/out of fullscreen. Detected in the /// monitor only WHILE FORWARDING, for the same reason as the ⌃⌥⇧ combos: a captured stream view /// swallows keys, so the Stream menu's identical ⌃⌘F equivalent never reaches it; released, the @@ -140,13 +148,13 @@ public final class InputCapture { public var onToggleFullscreen: (() -> Void)? #if os(iOS) - /// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides: + /// Windows VKs of the three modifier classes in the ⌃⌥⇧ chords, both L/R sides: /// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream. private static let chordModifierVKs: Set = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1] /// Whether Control AND Option AND Shift are all currently held (either side of each counts) — - /// the modifier precondition for the iPad ⌃⌥⇧Q release chord. - private var hasReleaseChordModifiers: Bool { + /// the modifier precondition for the iPad ⌃⌥⇧ chords (Q releases capture, A mutes the mic). + private var hasChordModifiers: Bool { let m = chordModifiersDown return (m.contains(0xA2) || m.contains(0xA3)) // control && (m.contains(0xA4) || m.contains(0xA5)) // option @@ -284,6 +292,10 @@ public final class InputCapture { self.suppressedVK = 0x53 self.onCycleStats?() return nil + case 0 /* A */: + self.suppressedVK = 0x41 + self.onToggleMicMute?() + return nil default: break } @@ -704,7 +716,7 @@ public final class InputCapture { } } #if os(iOS) - // Track Control/Option/Shift for the ⌃⌥⇧Q release chord below — in both forwarding + // Track Control/Option/Shift for the ⌃⌥⇧ chords below — in both forwarding // states (like `cmdKeysDown`) so a modifier held before capture engaged still counts. if Self.chordModifierVKs.contains(vk) { if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) } @@ -732,11 +744,19 @@ public final class InputCapture { // otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host; // the ⌃⌥⇧ modifiers were forwarded as they went down and are flushed by the release // path (setCaptured(false) → releaseAll). VK 0x51 is layout-independent (physical Q). - if pressed, vk == 0x51, self.hasReleaseChordModifiers { + if pressed, vk == 0x51, self.hasChordModifiers { self.suppressedVK = 0x51 self.onReleaseCapture?() return } + // ⌃⌥⇧A mutes/unmutes the mic uplink — same detection, same latching, and needed here + // for the same reason as on macOS: a captured iPad swallows the Stream menu's + // identical key equivalent. VK 0x41 is layout-independent (physical A). + if pressed, vk == 0x41, self.hasChordModifiers { + self.suppressedVK = 0x41 + self.onToggleMicMute?() + return + } #endif // Release direction of the toggle: GC's Esc-down can beat the NSEvent // monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved). diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index c568b905..60061f66 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -881,6 +881,12 @@ public final class StreamLayerView: NSView { guard self?.window?.isKeyWindow == true else { return } NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil) } + capture.onToggleMicMute = { [weak self] in + // Session-level state the view doesn't own — post to the app (same routing as the + // fullscreen chord), so the captured and released paths end at one toggle. + guard self?.window?.isKeyWindow == true else { return } + NotificationCenter.default.post(name: .punktfunkToggleMicMute, object: nil) + } capture.onCycleStats = { [weak self] in guard self?.window?.isKeyWindow == true else { return } // Advance the shared tier setting directly — every @AppStorage reader (the HUD's diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index 7673291c..fa3aa325 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -424,6 +424,12 @@ public final class StreamViewController: StreamViewControllerBase { capture.onReleaseCapture = { [weak self] in self?.setCaptured(false) } + // ⌃⌥⇧A mutes/unmutes the mic uplink. Session state this controller doesn't own, so it + // posts to the app exactly as the macOS chord does — the Stream menu's identical + // equivalent (which a captured scene swallows) ends at the same toggle. + capture.onToggleMicMute = { + NotificationCenter.default.post(name: .punktfunkToggleMicMute, object: nil) + } capture.onPreempted = { [weak self] in self?.setCaptured(false) } diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 8ede87ab..7b68d740 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -200,6 +200,12 @@ extension Notification.Name { /// state. macOS only. public static let punktfunkToggleFullscreen = Notification.Name("io.unom.punktfunk.toggle-fullscreen") + /// Posted by InputCapture's chord path (⌃⌥⇧A) when the combo fires while input is CAPTURED — + /// the state in which the Stream menu's identical key equivalent never reaches the app. The + /// live session's owner (ContentView) flips the session's mic mute. Released, the menu item + /// handles the same combo directly; both end at `SessionModel.toggleMicMute`. + public static let punktfunkToggleMicMute = Notification.Name("io.unom.punktfunk.toggle-mic-mute") + /// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`, /// which runs in the app's process): the app tears the active session down deliberately /// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` —