4e00037a89
apple / swift (push) Successful in 1m4s
android / android (push) Successful in 4m33s
ci / rust (push) Successful in 5m4s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m12s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m48s
apple / screenshots (push) Successful in 5m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m24s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m46s
Stream reliability - Default to the stage-2 presenter (VTDecompressionSession + CAMetalLayer): it detects and recovers a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no app-side recovery (confirmed Apple limitation). Stage 1 is now a DEBUG-only presenter toggle, plus the automatic no-Metal fallback. - Stage-2 pixel-perfect: render the drawable at the decoded size (shader stays 1:1 = identity) and let the layer's contentsGravity scale via the system compositor — the same path stage-1's videoGravity used — instead of scaling in-shader. - Loss recovery in both pumps is now a persistent awaitingIDR want, retried until an IDR actually lands, so a keyframe request swallowed by the throttle can't strand a frozen frame; 100 ms keyframe throttle to match the Android path. - Fix "Publishing changes from within view updates": defer the HostStore writes out of the .onChange(of: model.phase) callback. - Move AVAudioSession setActive/setCategory off the main thread (async on a shared serial queue) to stop the UI-stall warning. Controllers - Rumble: capped-exponential backoff when the gamecontrollerd.haptics XPC breaks (-4811) so a transient server interruption self-heals instead of cascading; playsHapticsOnly so a controller engine doesn't join the always-active streaming audio session. - Host cards: iPad pointer "magnet" hover effect; iPhone press scale + light haptic. UI / design - Ship Geist (SIL OFL 1.1) as the app font (bundled OTFs + registration), with the license surfaced in Acknowledgements. - Restructure iOS/iPadOS Settings into a category NavigationSplitView; resolution wheel with custom-resolution entry; 10-bit HDR toggle in Display. - Industrial host-card redesign (left-aligned, bold, brand monogram tiles). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
108 lines
5.2 KiB
Swift
108 lines
5.2 KiB
Swift
// 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(.geist(11, relativeTo: .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(.geist(11, relativeTo: .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(.geist(11, relativeTo: .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(.geist(12, relativeTo: .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(.geist(12, relativeTo: .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)
|
||
}
|
||
}
|