Until now the only way to stop sending the room was to end the session and turn "Send microphone to the host" off in Settings — a per-app setting for something that is really a per-conversation act. The mic now mutes from inside the stream: a button on the HUD card, the Stream menu's ⌃⌥⇧A (macOS menu bar and iPad hardware keyboards), the same chord while input is captured (both platforms detect it in InputCapture, where the reserved ⌃⌥⇧Q/D/S already live — ⌃⌥⇧M is the mouse-model flip, cross-client, so the mic gets A), and on iPhone/iPad a mic disc beside the touch exit for the stats tiers whose HUD carries no buttons. Muting is local and instant: it gates capture on this device, the host is never asked and never told. The muted state gets its own badge over the stream — independent of the stats overlay, because "am I muted?" is not a statistic and the overlay is exactly what a player turns off. The badge is also the way back: tapping it unmutes, which is the guaranteed path for a touch user who muted with the overlay off. Mute is session state and is deliberately not persisted. Every stream starts live if the mic is enabled at all, rather than carrying a mute nobody remembers making into a call three days later. The mechanism is the one wave 1 built. `SessionAudio.setMicMuted` — which mutes the voice processor's input on the combined engine and pauses the capture engine on the split one — stays the single muting path; what changes is that it now takes an EFFECTIVE mute the session composes from its two reasons: the user's mute and the background keep-alive's privacy mute. Neither can clear the other, so a user who muted before pocketing their phone comes back still muted, and backgrounding no longer un-mutes anyone on return. It also latches the state, so a mute made while the microphone permission prompt is still open lands on the engine that grant creates instead of being lost. The control is offered only where there is something to mute: the session's resolved `micEnabled` (a profile can turn the mic on or off), a platform with an app-accessible input (never tvOS), and a TCC grant the OS hasn't refused. Absent rather than greyed on the HUD and the touch discs, greyed on the menu, and the macOS start-of-stream banner only teaches ⌃⌥⇧A when the session actually sends a microphone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
327 lines
17 KiB
Swift
327 lines
17 KiB
Swift
// The streaming overlay HUD, tiered by StatsVerbosity (the Android client's 3-tier semantics):
|
||
// * compact — one glass-pill line: fps · end-to-end p50 · throughput (+ loss when lossy);
|
||
// * normal — mode + fps/throughput, the unified latency HEADLINE (design/stats-unification.md
|
||
// — end-to-end under stage-2, capture→received under the stage-1 fallback), the loss
|
||
// counter, a capture hint (shown until input is captured), and disconnect;
|
||
// * detailed — everything normal has plus the stage equation line(s) under the headline.
|
||
// `.off` never reaches this view (ContentView gates the overlay on the tier).
|
||
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#endif
|
||
|
||
struct StreamHUDView: View {
|
||
@ObservedObject var model: SessionModel
|
||
let connection: PunktfunkConnection
|
||
var placement: HUDPlacement = .topTrailing
|
||
let verbosity: StatsVerbosity
|
||
|
||
var body: some View {
|
||
// .off is gated upstream (ContentView only mounts the HUD when the tier is on) —
|
||
// render nothing if it ever slips through.
|
||
if verbosity != .off {
|
||
// ONE shared glass card wraps the tier-dependent content, so a verbosity change MORPHS
|
||
// this card — its frame (and, on iOS, its clamped corner) animate to the new size — rather
|
||
// than cross-fading a whole new card in. Only the inner content switches per tier.
|
||
tierContent
|
||
.padding(cardPadding)
|
||
.glassBackground(cardShape)
|
||
.padding(edgeInset)
|
||
}
|
||
}
|
||
|
||
/// The tier-dependent content, unwrapped (the shared card in `body` supplies the padding +
|
||
/// glass background). Compact is a one-line pill; normal/detailed the full stack.
|
||
@ViewBuilder private var tierContent: some View {
|
||
if verbosity == .compact {
|
||
compactContent
|
||
} else {
|
||
fullContent
|
||
}
|
||
}
|
||
|
||
// MARK: - Compact tier
|
||
|
||
/// One line: `{fps} fps · {e2e p50} ms · {mbps} Mb/s`. The ms segment is the best available
|
||
/// latency headline (stage-2 end-to-end, else the stage-1 capture→received) and is omitted until
|
||
/// either is valid. Loss appends in the same quiet styling the full HUD's lost line uses.
|
||
private var compactContent: some View {
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color.accentColor)
|
||
.frame(width: 7, height: 7)
|
||
Text(compactLine)
|
||
.font(.system(.caption, design: .monospaced))
|
||
if model.lostFrames > 0 {
|
||
Text("· lost \(model.lostFrames)")
|
||
.font(.system(.caption2, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var compactLine: String {
|
||
var parts = ["\(model.fps) fps"]
|
||
if model.endToEndValid {
|
||
// Floor-shaved (design/apple-presentation-rebuild.md): the OS present pipeline's
|
||
// fixed depth is excluded, so the headline describes Punktfunk's own latency.
|
||
parts.append(String(format: "%.1f ms", model.endToEndAdjP50Ms))
|
||
} else if model.hostNetworkValid {
|
||
parts.append(String(format: "%.1f ms", model.hostNetworkP50Ms))
|
||
}
|
||
parts.append(String(format: "%.1f Mb/s", model.mbps))
|
||
return parts.joined(separator: " · ")
|
||
}
|
||
|
||
// MARK: - Normal / detailed tiers
|
||
|
||
private var fullContent: some View {
|
||
VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) {
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color.accentColor)
|
||
.frame(width: 7, height: 7)
|
||
Text("\(connection.width)×\(connection.height)@\(connection.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
|
||
.font(.system(.caption, design: .monospaced))
|
||
// Which settings profile this session resolved to, if any (design §5.2). Near-zero
|
||
// cost, and it answers "which profile am I on?" without leaving the stream —
|
||
// otherwise the only evidence is the settings themselves, which is a guessing game.
|
||
if let profile = model.settings.profileName {
|
||
Text("· \(profile)")
|
||
.font(.system(.caption, design: .monospaced))
|
||
.foregroundStyle(
|
||
Color(hex: model.settings.profileAccent ?? "") ?? Color.accentColor)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
if model.endToEndValid {
|
||
// Stage-2: the end-to-end headline (capture→on-glass, measured directly, skew-
|
||
// corrected) — "(same-host clock)" when the host didn't answer the skew
|
||
// handshake. FLOOR-SHAVED (design/apple-presentation-rebuild.md): the OS present
|
||
// pipeline's fixed depth is excluded so the number describes Punktfunk's own
|
||
// latency; the detailed tier shows the excluded floor as its own line, and the
|
||
// stats log keeps the raw values.
|
||
Text("end-to-end \(model.endToEndAdjP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndAdjP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||
.font(.system(.caption2, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
// The equation (detailed tier only): the stages tiling the headline interval
|
||
// (per-window p50s — they only approximately sum to the directly-measured
|
||
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
||
// into host + network (phase 2); an old host keeps the combined term. The
|
||
// display term is floor-shaved like the headline, so the equation still sums.
|
||
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||
if model.splitValid {
|
||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
||
.font(.system(.caption2, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
Text("= host+network \(model.hostNetworkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayAdjP50Ms, specifier: "%.1f")")
|
||
.font(.system(.caption2, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
if model.osFloorValid {
|
||
// The excluded OS term, kept visible for honesty: display-pipeline
|
||
// minimum no client can pace under (~2 refresh intervals composited).
|
||
Text("os present +\(model.osFloorP50Ms, specifier: "%.1f") excluded (display pipeline minimum)")
|
||
.font(.system(.caption2, design: .monospaced))
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
// Client-queue wait (reassembly receipt → decode pull, ABI v9 split): ~0 on
|
||
// a healthy stream and hidden as noise; shown from 2 ms — a persistent value
|
||
// is a client-side standing backlog that pre-split builds displayed as
|
||
// "network" (the 2026-07 two-pair plateau). The core's standing-latency
|
||
// bleed logs alongside when it acts on the same state.
|
||
if model.clientQueueValid && model.clientQueueP50Ms >= 2 {
|
||
Text("client queue +\(model.clientQueueP50Ms, specifier: "%.1f") (receive backlog — standing if it persists)")
|
||
.font(.system(.caption2, design: .monospaced))
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
} else if model.hostNetworkValid {
|
||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||
// per-frame stamp, so the honest headline ends at receipt. The host/network
|
||
// split still applies there (receipt is presenter-independent) — it becomes the
|
||
// only equation line (detailed tier); without it, host+network IS the whole
|
||
// measured interval.
|
||
Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
|
||
.font(.system(.caption2, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
if verbosity == .detailed && model.splitValid {
|
||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
|
||
.font(.system(.caption2, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
if model.lostFrames > 0 {
|
||
// Unrecoverable network drops this window; hidden while the link is clean.
|
||
// String(format:) rather than specifier interpolation: the literal % would
|
||
// otherwise land in the LocalizedStringKey's format string as a bogus conversion.
|
||
Text(String(format: "lost %d (%.1f%%)", model.lostFrames, model.lostPct))
|
||
.font(.system(.caption2, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
// Capture hint, shown only until input is captured — how to grab it. The RELEASE
|
||
// shortcut is intentionally not surfaced in the overlay (it lives on the Stream menu
|
||
// and, on macOS, the start-of-stream banner), keeping the HUD uncluttered while playing.
|
||
#if os(macOS)
|
||
if !model.mouseCaptured {
|
||
Text("Click the stream to capture input")
|
||
.font(.geist(11, relativeTo: .caption2))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
#elseif os(iOS)
|
||
// Touch always plays directly; ⌘⎋ (hardware keyboard) captures kb/mouse.
|
||
if !model.mouseCaptured {
|
||
Text("⌘⎋ captures keyboard & mouse")
|
||
.font(.geist(11, relativeTo: .caption2))
|
||
.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
|
||
// focusable control would steal the controller's A press from the host) NOR a hint
|
||
// line: the exits are the hold gestures the start-of-stream banner teaches (hold
|
||
// the remote's Back; hold L1+R1+Start+Select on a pad).
|
||
#if os(macOS)
|
||
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
|
||
.font(.geist(12, relativeTo: .caption))
|
||
#elseif os(iOS)
|
||
Button("Disconnect") { model.disconnect() }
|
||
.font(.geist(12, relativeTo: .caption))
|
||
#endif
|
||
}
|
||
}
|
||
|
||
#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
|
||
/// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
|
||
private var cardPadding: CGFloat {
|
||
#if os(tvOS)
|
||
return 16
|
||
#else
|
||
return 10
|
||
#endif
|
||
}
|
||
|
||
/// The OUTER gap between the card and the screen edge. On iOS the card hugs a physically
|
||
/// rounded display corner, so it sits a little further in and pairs with a concentric corner
|
||
/// radius (below); tvOS floats it well clear of the TV's overscan-ish edge; macOS windows
|
||
/// keep the classic 10.
|
||
private var edgeInset: CGFloat {
|
||
#if os(iOS)
|
||
return 14
|
||
#elseif os(tvOS)
|
||
return 24
|
||
#else
|
||
return 10
|
||
#endif
|
||
}
|
||
|
||
/// The card's corner radius. On iOS it's concentric with the physical display corner —
|
||
/// `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform right around the
|
||
/// corner instead of a small-radius card cutting into the very rounded glass. Clamped so a
|
||
/// flat-cornered device (or a hidden radius) still gets a sensibly rounded card.
|
||
private var cardCornerRadius: CGFloat {
|
||
#if os(iOS)
|
||
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
|
||
#elseif os(tvOS)
|
||
return 16 // scales with the roomier padding
|
||
#else
|
||
return 10
|
||
#endif
|
||
}
|
||
|
||
/// The card background shape — a continuous (squircle) rounded rectangle, matching the curve
|
||
/// Apple's hardware display corners use so the concentric inset actually reads as parallel.
|
||
private var cardShape: RoundedRectangle {
|
||
RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous)
|
||
}
|
||
}
|
||
|
||
#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 {
|
||
/// The physical display's corner radius. There's no public API for it, so read the private
|
||
/// `_displayCornerRadius` via KVC on the active window scene's screen, guarded by a fallback that
|
||
/// approximates a modern rounded device — a future OS that hides the key just yields a slightly
|
||
/// less-perfect inset, never a crash. The key is assembled from parts so it isn't a plain literal
|
||
/// in the binary; note the App Store private-API consideration regardless.
|
||
static var displayCornerRadius: CGFloat {
|
||
let key = ["_display", "Corner", "Radius"].joined()
|
||
guard
|
||
let screen = UIApplication.shared.connectedScenes
|
||
.compactMap({ $0 as? UIWindowScene })
|
||
.first?.screen,
|
||
let radius = screen.value(forKey: key) as? NSNumber,
|
||
radius.doubleValue > 0
|
||
else { return 44 }
|
||
return CGFloat(radius.doubleValue)
|
||
}
|
||
}
|
||
#endif
|