Files
punktfunk/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift
T
enricobuehler f48c739a9c fix build
improve iOS & iPadOS UI
2026-06-19 15:49:48 +02:00

108 lines
5.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The streaming overlay HUD: mode + fps/throughput, the capture→client (and, under the stage-2
// presenter, capture→present) latency lines, the platform input hint, and disconnect.
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: 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))
}
if model.latencyValid {
// Capture→client-receipt (skew-corrected); excludes the layer's decode+present —
// see LatencyMeter. "(same-host)" when the host didn't answer the skew handshake.
Text("capture→client \(model.latencyP50Ms, specifier: "%.1f")/\(model.latencyP95Ms, specifier: "%.1f") ms p50/p95\(model.latencySkewCorrected ? "" : " (same-host)")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
}
if model.presentLatencyValid {
// Capture→present (glass-to-glass, modulo host render→capture) — stage-2 presenter
// only; stage-1's layer presents internally with no per-frame stamp.
Text("capture→present \(model.presentLatencyP50Ms, specifier: "%.1f")/\(model.presentLatencyP95Ms, specifier: "%.1f") ms p50/p95\(model.presentLatencySkewCorrected ? "" : " (same-host)")")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.secondary)
}
// While captured the cursor is hidden+frozen, so the button is keyboard-only
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
#if os(macOS)
Text(model.mouseCaptured
? "⌘⎋ releases the mouse"
: "Click the stream to capture input")
.font(.caption2)
.foregroundStyle(.secondary)
// The client-side cursor (⌘⇧C) draws the local cursor over the stream instead of
// capturing it — the only accurate cursor for gamescope, whose capture has none.
Text("⌘⇧C toggles the on-screen cursor")
.font(.caption2)
.foregroundStyle(.secondary)
#elseif os(iOS)
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
Text(model.mouseCaptured
? "⌘⎋ releases keyboard & mouse"
: "⌘⎋ captures keyboard & mouse")
.font(.caption2)
.foregroundStyle(.secondary)
#endif
#if os(tvOS)
// No focusable control during play: a focusable button steals the controller's
// A press (the focus engine consumes it before the host sees it). Disconnect is
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
Text("Press Menu to disconnect")
.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)
#endif
}
.padding(10)
// Floating HUD over live video — the canonical Liquid-Glass overlay surface (26+);
// falls back to .regularMaterial below 26 (see GlassStyle).
.glassBackground(RoundedRectangle(cornerRadius: 10))
.padding(10)
}
}