feat(clients): tiered stats overlay everywhere — Compact/Normal/Detailed on every platform
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m10s
ci / web (push) Successful in 1m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m46s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m57s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m10s
ci / bench (push) Successful in 5m43s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
release / apple (push) Successful in 8m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 11m59s
android / android (push) Successful in 13m3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m39s
docker / deploy-docs (push) Successful in 18s
apple / screenshots (push) Successful in 5m32s
deb / build-publish (push) Successful in 14m54s
ci / rust (push) Successful in 23m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m44s
flatpak / build-publish (push) Failing after 35s
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m10s
ci / web (push) Successful in 1m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m46s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m57s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m10s
ci / bench (push) Successful in 5m43s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
release / apple (push) Successful in 8m10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 11m59s
android / android (push) Successful in 13m3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m39s
docker / deploy-docs (push) Successful in 18s
apple / screenshots (push) Successful in 5m32s
deb / build-publish (push) Successful in 14m54s
ci / rust (push) Successful in 23m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m44s
flatpak / build-publish (push) Failing after 35s
Ship the Android client's 3-tier stats-overlay semantics in every other client (design/stats-unification.md vocabulary): Off → Compact (one line: fps · e2e ms · Mb/s + loss flag) → Normal (mode + e2e p50/p95 + loss counters) → Detailed (decoder path, HDR tag, per-stage latency equation). Apple: new StatsVerbosity in PunktfunkKit persisted under punktfunk.statsVerbosity (migrates the legacy hudEnabled bool: explicit off → Off, else Normal). The existing three-finger tap (TouchMouse, trackpad/pointer modes only — touch passthrough untouched) now cycles the tiers instead of toggling, matching Android; ⌃⌥⇧S (menu + captured-state monitor) cycles the same ladder. Tiered StreamHUDView (compact glass pill / headline HUD / full equation HUD); the iOS corner disconnect also shows in Compact (the pill carries no button). Tier pickers on iOS, macOS, tvOS and the gamepad settings UI. Session stack (Linux + Windows + Deck share punktfunk-session): shared pf_client_core::trust::StatsVerbosity; Settings grows stats_verbosity with a show_stats fallback, and writes keep the legacy bool in sync so pre-tier binaries reading the same JSON agree on off vs on. Ctrl+Alt+Shift+S cycles the tier and re-renders the OSD immediately from the last stats window; the stdout stats: line always carries the full Detailed text so the shell status card and scripts keep a stable shape; --stats bumps Off → Normal without demoting a richer tier. Tier pickers in the GTK dialog, the WinUI settings page and the console-UI settings row; shortcut copy updated (GTK shortcuts window, Windows help, session README). The Windows legacy builtin path keeps its bool HUD. Tests: tier migration/round-trip in trust.rs, tiered stats_text output in pf-presenter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,8 +31,15 @@ struct ContentView: View {
|
|||||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
// The raw string is what @AppStorage observes (so cycles from any surface re-render this
|
||||||
|
// view); the absent-key default runs the legacy-hudEnabled migration once per init.
|
||||||
|
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||||
|
= StatsVerbosity.current.rawValue
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
|
/// The persisted overlay tier (unknown raw falls back to .normal, like the migration).
|
||||||
|
private var statsVerbosity: StatsVerbosity {
|
||||||
|
StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
|
||||||
|
}
|
||||||
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
|
/// The `codec` setting as a `PUNKTFUNK_CODEC_*` soft-preference byte (`0` = auto).
|
||||||
private var preferredCodecByte: UInt8 {
|
private var preferredCodecByte: UInt8 {
|
||||||
switch codec {
|
switch codec {
|
||||||
@@ -393,8 +400,10 @@ struct ContentView: View {
|
|||||||
displayMeter: model.displayStage
|
displayMeter: model.displayStage
|
||||||
)
|
)
|
||||||
.overlay(alignment: placement.alignment) {
|
.overlay(alignment: placement.alignment) {
|
||||||
if captureEnabled && hudEnabled {
|
if captureEnabled && statsVerbosity != .off {
|
||||||
StreamHUDView(model: model, connection: conn, placement: placement)
|
StreamHUDView(
|
||||||
|
model: model, connection: conn, placement: placement,
|
||||||
|
verbosity: statsVerbosity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -422,17 +431,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// Touch users have no menu / ⌘D, so when the HUD (and its Disconnect button)
|
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||||
// is hidden, keep a minimal always-reachable exit in a corner. It rides a
|
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||||
// material disc (like the HUD) so the glyph stays legible over a bright frame
|
// keep a minimal always-reachable exit in a corner. It rides a material disc
|
||||||
// — this is the sole touch disconnect path when stats are off.
|
// (like the HUD) so the glyph stays legible over a bright frame — this is the
|
||||||
|
// sole touch disconnect path in those tiers.
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
if captureEnabled && !hudEnabled {
|
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) {
|
||||||
Button { model.disconnect() } label: {
|
Button { model.disconnect() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.headline.weight(.semibold))
|
.font(.headline.weight(.semibold))
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
// Sole touch exit when the HUD is off — a floating glass disc
|
// Sole touch exit in the off/compact tiers — a floating glass disc
|
||||||
// over the frame (26+, material fallback). interactive: the disc
|
// over the frame (26+, material fallback). interactive: the disc
|
||||||
// IS the tap target, so the glass reacts to press.
|
// IS the tap target, so the glass reacts to press.
|
||||||
.glassBackground(Circle(), interactive: true)
|
.glassBackground(Circle(), interactive: true)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
|
// (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
|
// 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 —
|
// keys); InputCapture's monitor detects the same combos there and performs the same actions —
|
||||||
// the menu covers the released state and discoverability. The stats toggle just flips the
|
// the menu covers the released state and discoverability. The stats item cycles the shared
|
||||||
// shared `hudEnabled` setting; ContentView reads the same @AppStorage and reacts.
|
// `statsVerbosity` tier (off → compact → normal → detailed → off); ContentView reads the same
|
||||||
|
// @AppStorage and reacts.
|
||||||
//
|
//
|
||||||
// tvOS has no menu bar / hardware-keyboard command surface (disconnect there is the Siri
|
// 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
|
// Remote's Menu button, handled by ContentView's `.onExitCommand`), so this whole file is
|
||||||
@@ -36,12 +37,16 @@ extension FocusedValues {
|
|||||||
|
|
||||||
struct StreamCommands: Commands {
|
struct StreamCommands: Commands {
|
||||||
@FocusedValue(\.sessionFocus) private var session
|
@FocusedValue(\.sessionFocus) private var session
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
// The raw string so @AppStorage observes the shared key; the absent-key default runs the
|
||||||
|
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||||
|
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||||
|
= StatsVerbosity.current.rawValue
|
||||||
|
|
||||||
var body: some Commands {
|
var body: some Commands {
|
||||||
CommandMenu("Stream") {
|
CommandMenu("Stream") {
|
||||||
Button(hudEnabled ? "Hide Statistics" : "Show Statistics") {
|
Button("Cycle Statistics") {
|
||||||
hudEnabled.toggle()
|
let current = StatsVerbosity(rawValue: statsVerbosityRaw) ?? .normal
|
||||||
|
statsVerbosityRaw = current.next().rawValue
|
||||||
}
|
}
|
||||||
.keyboardShortcut("s", modifiers: [.control, .option, .shift])
|
.keyboardShortcut("s", modifiers: [.control, .option, .shift])
|
||||||
// Reaches the key window's stream view via NotificationCenter — capture is view
|
// Reaches the key window's stream view via NotificationCenter — capture is view
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// The streaming overlay HUD: mode + fps/throughput, the unified latency lines
|
// The streaming overlay HUD, tiered by StatsVerbosity (the Android client's 3-tier semantics):
|
||||||
// (design/stats-unification.md — end-to-end headline + the stage equation under stage-2, the
|
// * compact — one glass-pill line: fps · end-to-end p50 · throughput (+ loss when lossy);
|
||||||
// capture→received headline under the stage-1 fallback), the loss counter, the platform input
|
// * normal — mode + fps/throughput, the unified latency HEADLINE (design/stats-unification.md
|
||||||
// hint, and disconnect.
|
// — end-to-end under stage-2, capture→received under the stage-1 fallback), the loss
|
||||||
|
// counter, the platform input hint, 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 PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -10,8 +13,56 @@ struct StreamHUDView: View {
|
|||||||
@ObservedObject var model: SessionModel
|
@ObservedObject var model: SessionModel
|
||||||
let connection: PunktfunkConnection
|
let connection: PunktfunkConnection
|
||||||
var placement: HUDPlacement = .topTrailing
|
var placement: HUDPlacement = .topTrailing
|
||||||
|
let verbosity: StatsVerbosity
|
||||||
|
|
||||||
var body: some View {
|
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 == .compact {
|
||||||
|
compactPill
|
||||||
|
} else if verbosity != .off {
|
||||||
|
fullHUD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compact tier
|
||||||
|
|
||||||
|
/// One line on the glass pill: `{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 compactPill: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.glassBackground(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var compactLine: String {
|
||||||
|
var parts = ["\(model.fps) fps"]
|
||||||
|
if model.endToEndValid {
|
||||||
|
parts.append(String(format: "%.1f ms", model.endToEndP50Ms))
|
||||||
|
} 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 fullHUD: some View {
|
||||||
VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) {
|
VStack(alignment: placement.isTrailing ? .trailing : .leading, spacing: 4) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -26,11 +77,11 @@ struct StreamHUDView: View {
|
|||||||
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
Text("end-to-end \(model.endToEndP50Ms, specifier: "%.1f") ms p50 · \(model.endToEndP95Ms, specifier: "%.1f") p95 · capture→on-glass\(model.endToEndSkewCorrected ? "" : " (same-host clock)")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
// The equation: the stages tiling the headline interval (per-window p50s —
|
// The equation (detailed tier only): the stages tiling the headline interval
|
||||||
// they only approximately sum to the directly-measured total). With a host
|
// (per-window p50s — they only approximately sum to the directly-measured
|
||||||
// that reports per-AU timings (0xCF) the first term splits into host + network
|
// total). With a host that reports per-AU timings (0xCF) the first term splits
|
||||||
// (phase 2); an old host keeps the combined term.
|
// into host + network (phase 2); an old host keeps the combined term.
|
||||||
if model.hostNetworkValid && model.decodeValid && model.displayValid {
|
if verbosity == .detailed && model.hostNetworkValid && model.decodeValid && model.displayValid {
|
||||||
if model.splitValid {
|
if model.splitValid {
|
||||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f") + decode \(model.decodeP50Ms, specifier: "%.1f") + display \(model.displayP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
@@ -45,11 +96,12 @@ struct StreamHUDView: View {
|
|||||||
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
// Stage-1 fallback presenter: the layer decodes + presents internally with no
|
||||||
// per-frame stamp, so the honest headline ends at receipt. The host/network
|
// per-frame stamp, so the honest headline ends at receipt. The host/network
|
||||||
// split still applies there (receipt is presenter-independent) — it becomes the
|
// split still applies there (receipt is presenter-independent) — it becomes the
|
||||||
// only equation line; without it, host+network IS the whole measured interval.
|
// 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)")")
|
Text("capture→received \(model.hostNetworkP50Ms, specifier: "%.1f") ms p50 · \(model.hostNetworkP95Ms, specifier: "%.1f") p95\(model.hostNetworkSkewCorrected ? "" : " (same-host clock)")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if model.splitValid {
|
if verbosity == .detailed && model.splitValid {
|
||||||
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
|
Text("= host \(model.hostP50Ms, specifier: "%.1f") + network \(model.networkP50Ms, specifier: "%.1f")")
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.enable444) private var enable444 = true
|
@AppStorage(DefaultsKey.enable444) private var enable444 = true
|
||||||
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
@AppStorage(DefaultsKey.codec) private var codec = "auto"
|
||||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
// The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the
|
||||||
|
// legacy-hudEnabled migration (same pattern as ContentView/SettingsView).
|
||||||
|
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||||
|
= StatsVerbosity.current.rawValue
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
@@ -271,10 +274,12 @@ struct GamepadSettingsView: View {
|
|||||||
options: SettingsOptions.padTypes, current: gamepadType
|
options: SettingsOptions.padTypes, current: gamepadType
|
||||||
) { gamepadType = $0 },
|
) { gamepadType = $0 },
|
||||||
|
|
||||||
toggleRow(
|
choiceRow(
|
||||||
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay",
|
||||||
detail: "Resolution, frame rate, throughput and latency while streaming.",
|
detail: "How much to show while streaming — Compact is a one-line pill, "
|
||||||
value: $hudEnabled),
|
+ "Detailed adds the latency stage breakdown.",
|
||||||
|
options: SettingsOptions.statsVerbosities, current: statsVerbosityRaw
|
||||||
|
) { statsVerbosityRaw = $0 },
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position",
|
||||||
detail: "Which corner the statistics overlay sits in.",
|
detail: "Which corner the statistics overlay sits in.",
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ enum SettingsOptions {
|
|||||||
static let hudPlacements: [(label: String, tag: String)] =
|
static let hudPlacements: [(label: String, tag: String)] =
|
||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
|
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||||
|
static let statsVerbosities: [(label: String, tag: String)] =
|
||||||
|
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
|
/// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from.
|
||||||
/// AV1 appears only on devices with an AV1 hardware decoder (the same
|
/// AV1 appears only on devices with an AV1 hardware decoder (the same
|
||||||
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a
|
/// `AV1.hardwareDecodeSupported` gate SessionModel advertises by) — elsewhere it would be a
|
||||||
|
|||||||
@@ -385,13 +385,17 @@ extension SettingsView {
|
|||||||
|
|
||||||
@ViewBuilder var statisticsSection: some View {
|
@ViewBuilder var statisticsSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Show statistics overlay", isOn: $hudEnabled)
|
Picker("Statistics overlay", selection: $statsVerbosityRaw) {
|
||||||
|
ForEach(StatsVerbosity.allCases, id: \.rawValue) { tier in
|
||||||
|
Text(tier.label).tag(tier.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
Picker("Position", selection: $hudPlacement) {
|
Picker("Position", selection: $hudPlacement) {
|
||||||
ForEach(HUDPlacement.allCases) { placement in
|
ForEach(HUDPlacement.allCases) { placement in
|
||||||
Text(placement.label).tag(placement.rawValue)
|
Text(placement.label).tag(placement.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(!hudEnabled)
|
.disabled(statsVerbosityRaw == StatsVerbosity.off.rawValue)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Statistics")
|
Text("Statistics")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|||||||
@@ -54,10 +54,14 @@ extension SettingsView {
|
|||||||
// MARK: - Statistics
|
// MARK: - Statistics
|
||||||
|
|
||||||
static var statisticsFooter: String {
|
static var statisticsFooter: String {
|
||||||
let base = "Shows resolution, frame rate, throughput and latency in the chosen "
|
let base = "Shows streaming statistics in the chosen corner — Compact is a one-line "
|
||||||
+ "corner while streaming."
|
+ "pill, Normal adds resolution and latency, Detailed adds the latency stage "
|
||||||
#if os(macOS) || os(iOS)
|
+ "breakdown."
|
||||||
return base + " Toggle it any time with ⌃⌥⇧S."
|
#if os(macOS)
|
||||||
|
return base + " ⌃⌥⇧S cycles Off → Compact → Normal → Detailed any time."
|
||||||
|
#elseif os(iOS)
|
||||||
|
return base + " ⌃⌥⇧S or a three-finger tap cycles Off → Compact → Normal → Detailed "
|
||||||
|
+ "any time."
|
||||||
#else
|
#else
|
||||||
return base
|
return base
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||||||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||||||
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
@AppStorage(DefaultsKey.codec) var codec = "auto"
|
||||||
@AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true
|
// The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs
|
||||||
|
// the legacy-hudEnabled migration (same pattern as ContentView/StreamCommands).
|
||||||
|
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
|
||||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject var gamepads = GamepadManager.shared
|
@ObservedObject var gamepads = GamepadManager.shared
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -294,10 +296,6 @@ struct SettingsView: View {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hudEnabledTag: Binding<String> {
|
|
||||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
|
||||||
}
|
|
||||||
|
|
||||||
private var hdrEnabledTag: Binding<String> {
|
private var hdrEnabledTag: Binding<String> {
|
||||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||||
}
|
}
|
||||||
@@ -352,7 +350,7 @@ struct SettingsView: View {
|
|||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Statistics overlay",
|
title: "Statistics overlay",
|
||||||
options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag)
|
options: SettingsOptions.statsVerbosities, selection: $statsVerbosityRaw)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Statistics position", options: SettingsOptions.hudPlacements,
|
title: "Statistics position", options: SettingsOptions.hudPlacements,
|
||||||
selection: $hudPlacement)
|
selection: $hudPlacement)
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ public final class InputCapture {
|
|||||||
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
/// keyDown monitor only WHILE FORWARDING — that's the state in which the app's menu (which
|
||||||
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
|
/// 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.
|
/// captured-state delivery path; released, the events pass through and the menu handles them.
|
||||||
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S toggles the stats
|
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S cycles the stats
|
||||||
/// overlay. Main queue.
|
/// overlay tier (off → compact → normal → detailed). Main queue.
|
||||||
public var onReleaseCapture: (() -> Void)?
|
public var onReleaseCapture: (() -> Void)?
|
||||||
public var onDisconnect: (() -> Void)?
|
public var onDisconnect: (() -> Void)?
|
||||||
public var onToggleStats: (() -> Void)?
|
public var onCycleStats: (() -> Void)?
|
||||||
|
|
||||||
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
/// Fired when a newer InputCapture takes the process-global GC handler slots (the
|
||||||
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
/// singletons hold ONE handler each): the preempted owner must drop its capture
|
||||||
@@ -246,7 +246,7 @@ public final class InputCapture {
|
|||||||
return nil
|
return nil
|
||||||
case 1 /* S */:
|
case 1 /* S */:
|
||||||
self.suppressedVK = 0x53
|
self.suppressedVK = 0x53
|
||||||
self.onToggleStats?()
|
self.onCycleStats?()
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
|
// touch gesture model (clients/android .../TouchInput.kt) so the two touch clients feel
|
||||||
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
// identical. Two mouse modes share one gesture vocabulary — tap = left click · two-finger
|
||||||
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
// tap = right click · two-finger drag = scroll · tap-then-press-and-drag = held left drag
|
||||||
// (text selection / window moves) · three-finger tap = stats-HUD toggle:
|
// (text selection / window moves) · three-finger tap = cycles the stats overlay tiers
|
||||||
|
// (off → compact → normal → detailed, matching Android):
|
||||||
//
|
//
|
||||||
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
// * trackpad (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
||||||
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
// relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||||
@@ -164,7 +165,7 @@ final class TouchMouse {
|
|||||||
} else if !moved {
|
} else if !moved {
|
||||||
switch maxFingers {
|
switch maxFingers {
|
||||||
case 3...:
|
case 3...:
|
||||||
Self.toggleHUD() // in-stream stats-overlay toggle, same as Android
|
Self.cycleStats() // in-stream stats-tier cycle, same as Android
|
||||||
case 2: // two-finger tap → right click
|
case 2: // two-finger tap → right click
|
||||||
send?(.mouseButton(Button.right, down: true))
|
send?(.mouseButton(Button.right, down: true))
|
||||||
send?(.mouseButton(Button.right, down: false))
|
send?(.mouseButton(Button.right, down: false))
|
||||||
@@ -274,12 +275,11 @@ final class TouchMouse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Three-finger tap toggles the stats overlay — through the shared `hudEnabled` default,
|
/// Three-finger tap cycles the stats overlay tiers (off → compact → normal → detailed) —
|
||||||
/// which the app's HUD views observe via @AppStorage (so this needs no wiring to them).
|
/// through the shared `statsVerbosity` default, which the app's HUD views observe via
|
||||||
private static func toggleHUD() {
|
/// @AppStorage (so this needs no wiring to them). Same cycle as Android's triple-tap.
|
||||||
let defaults = UserDefaults.standard
|
private static func cycleStats() {
|
||||||
let on = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
StatsVerbosity.store(StatsVerbosity.current.next())
|
||||||
defaults.set(!on, forKey: DefaultsKey.hudEnabled)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -67,9 +67,15 @@ public enum DefaultsKey {
|
|||||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||||
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
|
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
|
||||||
/// Show the streaming statistics overlay (mode/fps/throughput/latency). On by default; toggle
|
/// LEGACY (pre-tiered overlay): the old boolean stats-overlay toggle. Kept ONLY as the
|
||||||
/// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware keyboard).
|
/// migration fallback `StatsVerbosity.current` reads when `statsVerbosity` was never
|
||||||
|
/// written (absent-or-true → .normal, explicit false → .off). Never written anymore.
|
||||||
public static let hudEnabled = "punktfunk.hudEnabled"
|
public static let hudEnabled = "punktfunk.hudEnabled"
|
||||||
|
/// The statistics overlay tier — a `StatsVerbosity` raw value ("off"/"compact"/"normal"/
|
||||||
|
/// "detailed"). Absent → migrated from the legacy `hudEnabled` bool (see above). Cycle it
|
||||||
|
/// while streaming with ⌃⌥⇧S (the cross-client Ctrl+Alt+Shift+S; macOS / hardware
|
||||||
|
/// keyboard) or a three-finger tap (touch), matching the Android client.
|
||||||
|
public static let statsVerbosity = "punktfunk.statsVerbosity"
|
||||||
/// Which corner the statistics overlay sits in — a `HUDPlacement` raw value
|
/// Which corner the statistics overlay sits in — a `HUDPlacement` raw value
|
||||||
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
/// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing.
|
||||||
public static let hudPlacement = "punktfunk.hudPlacement"
|
public static let hudPlacement = "punktfunk.hudPlacement"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// The stats overlay's verbosity tier — a port of the Android client's 3-tier overlay semantics
|
||||||
|
// so the two clients feel identical: Off → Compact (one-line pill) → Normal (headline stats) →
|
||||||
|
// Detailed (plus the latency stage equation). Persisted under `DefaultsKey.statsVerbosity`;
|
||||||
|
// the in-stream cycle surfaces (⌃⌥⇧S, the three-finger tap) advance it with
|
||||||
|
// `store(current.next())`, and every UI reader observes the same default via @AppStorage.
|
||||||
|
//
|
||||||
|
// Lives in PunktfunkKit (not the app) because the kit's input paths (TouchMouse's three-finger
|
||||||
|
// tap, InputCapture's captured-state ⌃⌥⇧S) cycle it directly.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// How much of the streaming statistics overlay to show. The raw values are stable on disk —
|
||||||
|
/// rename the cases freely, never the strings.
|
||||||
|
public enum StatsVerbosity: String, CaseIterable, Sendable {
|
||||||
|
case off, compact, normal, detailed
|
||||||
|
|
||||||
|
/// User-facing tier label (Settings pickers, the gamepad settings row).
|
||||||
|
public var label: String {
|
||||||
|
switch self {
|
||||||
|
case .off: return "Off"
|
||||||
|
case .compact: return "Compact"
|
||||||
|
case .normal: return "Normal"
|
||||||
|
case .detailed: return "Detailed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next tier in the cycle: off → compact → normal → detailed → off (wrapping) —
|
||||||
|
/// the ⌃⌥⇧S / three-finger-tap order, same as Android.
|
||||||
|
public func next() -> StatsVerbosity {
|
||||||
|
switch self {
|
||||||
|
case .off: return .compact
|
||||||
|
case .compact: return .normal
|
||||||
|
case .normal: return .detailed
|
||||||
|
case .detailed: return .off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted tier. When `statsVerbosity` has never been written, migrates from the
|
||||||
|
/// legacy `hudEnabled` bool the pre-tiered clients stored: absent-or-true → `.normal`
|
||||||
|
/// (the old "on" look minus the equation lines), explicit false → `.off`.
|
||||||
|
public static var current: StatsVerbosity {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
if let raw = defaults.string(forKey: DefaultsKey.statsVerbosity),
|
||||||
|
let tier = StatsVerbosity(rawValue: raw) {
|
||||||
|
return tier
|
||||||
|
}
|
||||||
|
if let legacy = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool, !legacy {
|
||||||
|
return .off
|
||||||
|
}
|
||||||
|
return .normal
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a tier (the cycle surfaces write through here; the Settings pickers write the
|
||||||
|
/// same key via @AppStorage).
|
||||||
|
public static func store(_ tier: StatsVerbosity) {
|
||||||
|
UserDefaults.standard.set(tier.rawValue, forKey: DefaultsKey.statsVerbosity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -594,13 +594,12 @@ public final class StreamLayerView: NSView {
|
|||||||
guard let self, self.window?.isKeyWindow == true else { return }
|
guard let self, self.window?.isKeyWindow == true else { return }
|
||||||
self.onDisconnectRequest?()
|
self.onDisconnectRequest?()
|
||||||
}
|
}
|
||||||
capture.onToggleStats = { [weak self] in
|
capture.onCycleStats = { [weak self] in
|
||||||
guard self?.window?.isKeyWindow == true else { return }
|
guard self?.window?.isKeyWindow == true else { return }
|
||||||
// Flip the shared setting directly — every @AppStorage reader (the HUD's visibility,
|
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
||||||
// the menu item's title) observes UserDefaults, so this is the same as the menu path.
|
// visibility/content, the Settings pickers) observes UserDefaults, so this is the
|
||||||
let defaults = UserDefaults.standard
|
// same as the menu path.
|
||||||
let current = defaults.object(forKey: DefaultsKey.hudEnabled) as? Bool ?? true
|
StatsVerbosity.store(StatsVerbosity.current.next())
|
||||||
defaults.set(!current, forKey: DefaultsKey.hudEnabled)
|
|
||||||
}
|
}
|
||||||
capture.start()
|
capture.start()
|
||||||
inputCapture = capture
|
inputCapture = capture
|
||||||
|
|||||||
@@ -670,7 +670,7 @@ pub fn shortcuts_window(parent: &adw::ApplicationWindow) -> gtk::ShortcutsWindow
|
|||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkShortcutsShortcut">
|
<object class="GtkShortcutsShortcut">
|
||||||
<property name="title">Toggle statistics overlay</property>
|
<property name="title">Cycle the statistics overlay (off · compact · normal · detailed)</property>
|
||||||
<property name="accelerator"><Control><Alt><Shift>s</property>
|
<property name="accelerator"><Control><Alt><Shift>s</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
use crate::trust::Settings;
|
use crate::trust::Settings;
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
|
use pf_client_core::trust::StatsVerbosity;
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
@@ -324,10 +325,13 @@ pub fn show(
|
|||||||
"Software",
|
"Software",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let stats_row = adw::SwitchRow::builder()
|
let stats_row = ChoiceRow::new(
|
||||||
.title("Show statistics overlay")
|
&dialog,
|
||||||
.subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live")
|
inline,
|
||||||
.build();
|
"Statistics overlay",
|
||||||
|
"Compact = fps · latency · bitrate in one line — Ctrl+Alt+Shift+S cycles the tiers live",
|
||||||
|
&["Off", "Compact", "Normal", "Detailed"],
|
||||||
|
);
|
||||||
let fullscreen_row = adw::SwitchRow::builder()
|
let fullscreen_row = adw::SwitchRow::builder()
|
||||||
.title("Start streams in fullscreen")
|
.title("Start streams in fullscreen")
|
||||||
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
.subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out")
|
||||||
@@ -338,7 +342,7 @@ pub fn show(
|
|||||||
stream.add(compositor_row.widget());
|
stream.add(compositor_row.widget());
|
||||||
stream.add(decoder_row.widget());
|
stream.add(decoder_row.widget());
|
||||||
stream.add(&fullscreen_row);
|
stream.add(&fullscreen_row);
|
||||||
stream.add(&stats_row);
|
stream.add(stats_row.widget());
|
||||||
|
|
||||||
let input = adw::PreferencesGroup::builder().title("Input").build();
|
let input = adw::PreferencesGroup::builder().title("Input").build();
|
||||||
// Which physical controller forwards as pad 0: automatic = the most recently connected
|
// Which physical controller forwards as pad 0: automatic = the most recently connected
|
||||||
@@ -483,7 +487,11 @@ pub fn show(
|
|||||||
compositor_row.set_selected(comp_i as u32);
|
compositor_row.set_selected(comp_i as u32);
|
||||||
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0);
|
||||||
decoder_row.set_selected(dec_i as u32);
|
decoder_row.set_selected(dec_i as u32);
|
||||||
stats_row.set_active(s.show_stats);
|
let stats_i = StatsVerbosity::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|v| *v == s.stats_verbosity())
|
||||||
|
.unwrap_or(0);
|
||||||
|
stats_row.set_selected(stats_i as u32);
|
||||||
fullscreen_row.set_active(s.fullscreen_on_stream);
|
fullscreen_row.set_active(s.fullscreen_on_stream);
|
||||||
inhibit_row.set_active(s.inhibit_shortcuts);
|
inhibit_row.set_active(s.inhibit_shortcuts);
|
||||||
mic_row.set_active(s.mic_enabled);
|
mic_row.set_active(s.mic_enabled);
|
||||||
@@ -509,7 +517,9 @@ pub fn show(
|
|||||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||||
.to_string();
|
.to_string();
|
||||||
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string();
|
||||||
s.show_stats = stats_row.is_active();
|
s.set_stats_verbosity(
|
||||||
|
StatsVerbosity::ALL[(stats_row.selected() as usize).min(StatsVerbosity::ALL.len() - 1)],
|
||||||
|
);
|
||||||
s.fullscreen_on_stream = fullscreen_row.is_active();
|
s.fullscreen_on_stream = fullscreen_row.is_active();
|
||||||
s.inhibit_shortcuts = inhibit_row.is_active();
|
s.inhibit_shortcuts = inhibit_row.is_active();
|
||||||
s.mic_enabled = mic_row.is_active();
|
s.mic_enabled = mic_row.is_active();
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ Reads the same identity / known-hosts / settings stores as the desktop client
|
|||||||
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
|
||||||
|
|
||||||
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
|
||||||
`stats: …` once per second (Ctrl+Alt+Shift+S toggles, `--stats` forces on), one
|
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
|
||||||
|
text, whatever the OSD shows; `--stats` forces the overlay on), one
|
||||||
`{"error"|"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: `0`
|
`{"error"|"ended": …}` JSON line on the way out. Logs go to stderr. Exit codes: `0`
|
||||||
clean end, `2` connect failed, `3` trust rejected / pairing required, `4` presenter
|
clean end, `2` connect failed, `3` trust rejected / pairing required, `4` presenter
|
||||||
init failed.
|
init failed.
|
||||||
@@ -31,7 +32,9 @@ releases), Ctrl+Alt+Shift+D disconnects, F11 toggles fullscreen; the controller
|
|||||||
chord (L1+R1+Start+Select, hold to disconnect) works the same.
|
chord (L1+R1+Start+Select, hold to disconnect) works the same.
|
||||||
|
|
||||||
The default build carries the Skia console UI (`ui` feature): the stats OSD and capture
|
The default build carries the Skia console UI (`ui` feature): the stats OSD and capture
|
||||||
hint render in-window (Ctrl+Alt+Shift+S toggles both the OSD and the stdout mirror).
|
hint render in-window. Ctrl+Alt+Shift+S cycles the OSD tier live — Off → Compact (one
|
||||||
|
line: fps · latency · Mb/s) → Normal (mode + end-to-end percentiles) → Detailed (decoder
|
||||||
|
path + per-stage latency equation); any tier but Off also emits the stdout mirror.
|
||||||
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
|
`--no-default-features` is the ~5 MB power-user build — same streaming, stats on stdout
|
||||||
only, no Skia anywhere in the dependency tree.
|
only, no Skia anywhere in the dependency tree.
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
|||||||
),
|
),
|
||||||
fullscreen: fullscreen_mode(),
|
fullscreen: fullscreen_mode(),
|
||||||
window_pos: window_pos(),
|
window_pos: window_pos(),
|
||||||
print_stats: settings_at_start.show_stats || arg_flag("--stats"),
|
// `--stats` forces the overlay visible without demoting a richer chosen tier.
|
||||||
|
stats_verbosity: match settings_at_start.stats_verbosity() {
|
||||||
|
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||||
|
v => v,
|
||||||
|
},
|
||||||
json_status,
|
json_status,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||||
|
|||||||
@@ -322,7 +322,12 @@ mod session_main {
|
|||||||
window_title: format!("Punktfunk · {title}"),
|
window_title: format!("Punktfunk · {title}"),
|
||||||
fullscreen,
|
fullscreen,
|
||||||
window_pos: window_pos(),
|
window_pos: window_pos(),
|
||||||
print_stats: settings.show_stats || arg_flag("--stats"),
|
// `--stats` forces the overlay visible (tooling/debug runs) without
|
||||||
|
// demoting an explicitly chosen richer tier.
|
||||||
|
stats_verbosity: match settings.stats_verbosity() {
|
||||||
|
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||||
|
v => v,
|
||||||
|
},
|
||||||
json_status: true,
|
json_status: true,
|
||||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||||
// This host's card carries the accent bar in the desktop client now.
|
// This host's card carries the accent bar in the desktop client now.
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ const STREAM_SHORTCUTS: &[(&str, &str)] = &[
|
|||||||
"Release captured input (click the stream to recapture)",
|
"Release captured input (click the stream to recapture)",
|
||||||
),
|
),
|
||||||
("Ctrl+Alt+Shift+D", "Disconnect"),
|
("Ctrl+Alt+Shift+D", "Disconnect"),
|
||||||
("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"),
|
(
|
||||||
|
"Ctrl+Alt+Shift+S",
|
||||||
|
"Cycle the statistics overlay (off \u{00B7} compact \u{00B7} normal \u{00B7} detailed)",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"LB+RB+Start+Back",
|
"LB+RB+Start+Back",
|
||||||
"Controller: release input / leave fullscreen \u{2014} hold to disconnect",
|
"Controller: release input / leave fullscreen \u{2014} hold to disconnect",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
use super::style::*;
|
use super::style::*;
|
||||||
use super::{AppCtx, Screen};
|
use super::{AppCtx, Screen};
|
||||||
use crate::trust::Settings;
|
use crate::trust::Settings;
|
||||||
|
use pf_client_core::trust::StatsVerbosity;
|
||||||
use punktfunk_core::config::GamepadPref;
|
use punktfunk_core::config::GamepadPref;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use windows_reactor::*;
|
use windows_reactor::*;
|
||||||
@@ -46,6 +47,14 @@ const GAMEPADS: &[(&str, &str)] = &[
|
|||||||
("xboxone", "Xbox One"),
|
("xboxone", "Xbox One"),
|
||||||
("dualshock4", "DualShock 4"),
|
("dualshock4", "DualShock 4"),
|
||||||
];
|
];
|
||||||
|
/// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder
|
||||||
|
/// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window.
|
||||||
|
const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
|
||||||
|
(StatsVerbosity::Off, "Off"),
|
||||||
|
(StatsVerbosity::Compact, "Compact"),
|
||||||
|
(StatsVerbosity::Normal, "Normal"),
|
||||||
|
(StatsVerbosity::Detailed, "Detailed"),
|
||||||
|
];
|
||||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||||
const COMPOSITORS: &[(&str, &str)] = &[
|
const COMPOSITORS: &[(&str, &str)] = &[
|
||||||
@@ -328,15 +337,14 @@ pub(crate) fn settings_page(
|
|||||||
)
|
)
|
||||||
.tooltip("Sends the default microphone to the host's virtual mic source.");
|
.tooltip("Sends the default microphone to the host's virtual mic source.");
|
||||||
|
|
||||||
let hud_toggle = setting_toggle(
|
let (hud_names, hud_i) = presets(STATS_TIERS, |v| *v == s.stats_verbosity());
|
||||||
ctx,
|
let hud_combo = setting_combo(ctx, "Stats overlay (HUD)", hud_names, hud_i, |s, i| {
|
||||||
"Show the stats overlay (HUD)",
|
s.set_stats_verbosity(STATS_TIERS[i].0);
|
||||||
s.show_stats,
|
})
|
||||||
|s, on| s.show_stats = on,
|
|
||||||
)
|
|
||||||
.tooltip(
|
.tooltip(
|
||||||
"The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \
|
"How much the in-stream overlay shows: Compact (fps \u{00B7} latency \u{00B7} bitrate \
|
||||||
Ctrl+Alt+Shift+S toggles it live while streaming.",
|
in one line) \u{2192} Normal \u{2192} Detailed (decode path and per-stage latency). \
|
||||||
|
Ctrl+Alt+Shift+S cycles the tiers live while streaming.",
|
||||||
);
|
);
|
||||||
|
|
||||||
let licenses_button = {
|
let licenses_button = {
|
||||||
@@ -368,7 +376,7 @@ pub(crate) fn settings_page(
|
|||||||
codec_combo.into(),
|
codec_combo.into(),
|
||||||
bitrate_box.into(),
|
bitrate_box.into(),
|
||||||
hdr_toggle.into(),
|
hdr_toggle.into(),
|
||||||
hud_toggle.into(),
|
hud_combo.into(),
|
||||||
]);
|
]);
|
||||||
controls
|
controls
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -254,6 +254,50 @@ pub fn probe_reachable_many(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How much the on-stream statistics overlay shows — the Android client's tiers, shared
|
||||||
|
/// across every client (design/stats-unification.md): each tier is a strict superset of
|
||||||
|
/// the previous. Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum StatsVerbosity {
|
||||||
|
Off,
|
||||||
|
/// One glanceable line: fps · end-to-end ms · Mb/s.
|
||||||
|
Compact,
|
||||||
|
/// Stream mode plus the end-to-end latency percentiles and loss counters.
|
||||||
|
Normal,
|
||||||
|
/// Everything: decoder path, HDR tags, and the per-stage latency equation.
|
||||||
|
Detailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsVerbosity {
|
||||||
|
/// Cycle order (also the settings pickers' option order).
|
||||||
|
pub const ALL: [StatsVerbosity; 4] = [
|
||||||
|
StatsVerbosity::Off,
|
||||||
|
StatsVerbosity::Compact,
|
||||||
|
StatsVerbosity::Normal,
|
||||||
|
StatsVerbosity::Detailed,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The next tier in the live cycle, wrapping back to Off.
|
||||||
|
pub fn next(self) -> StatsVerbosity {
|
||||||
|
match self {
|
||||||
|
StatsVerbosity::Off => StatsVerbosity::Compact,
|
||||||
|
StatsVerbosity::Compact => StatsVerbosity::Normal,
|
||||||
|
StatsVerbosity::Normal => StatsVerbosity::Detailed,
|
||||||
|
StatsVerbosity::Detailed => StatsVerbosity::Off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
StatsVerbosity::Off => "Off",
|
||||||
|
StatsVerbosity::Compact => "Compact",
|
||||||
|
StatsVerbosity::Normal => "Normal",
|
||||||
|
StatsVerbosity::Detailed => "Detailed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
@@ -301,10 +345,16 @@ pub struct Settings {
|
|||||||
/// `default = true`: the Linux stores never carried this and always advertised.
|
/// `default = true`: the Linux stores never carried this and always advertised.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub hdr_enabled: bool,
|
pub hdr_enabled: bool,
|
||||||
/// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S).
|
/// Legacy on/off for the stats overlay — superseded by `stats_verbosity` but kept
|
||||||
/// `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted this as `show_hud`.
|
/// written in sync (`set_stats_verbosity`) so pre-tier binaries reading the same
|
||||||
|
/// file keep working. `alias`: the pre-unification WinUI shell (≤ 0.8.4) persisted
|
||||||
|
/// this as `show_hud`.
|
||||||
#[serde(alias = "show_hud")]
|
#[serde(alias = "show_hud")]
|
||||||
pub show_stats: bool,
|
pub show_stats: bool,
|
||||||
|
/// Stats overlay tier. `None` = a pre-tier store; resolve through
|
||||||
|
/// [`Settings::stats_verbosity`], which falls back to `show_stats`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stats_verbosity: Option<StatsVerbosity>,
|
||||||
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
/// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge
|
||||||
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
/// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless.
|
||||||
pub fullscreen_on_stream: bool,
|
pub fullscreen_on_stream: bool,
|
||||||
@@ -322,6 +372,23 @@ fn default_true() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
|
/// The stats-overlay tier, resolving pre-tier stores: an old `show_stats = false`
|
||||||
|
/// reads as Off, everything else as Normal (≈ what the pre-tier overlay showed).
|
||||||
|
pub fn stats_verbosity(&self) -> StatsVerbosity {
|
||||||
|
self.stats_verbosity.unwrap_or(if self.show_stats {
|
||||||
|
StatsVerbosity::Normal
|
||||||
|
} else {
|
||||||
|
StatsVerbosity::Off
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the tier, keeping the legacy `show_stats` bool coherent for pre-tier
|
||||||
|
/// binaries that read the same settings file.
|
||||||
|
pub fn set_stats_verbosity(&mut self, v: StatsVerbosity) {
|
||||||
|
self.stats_verbosity = Some(v);
|
||||||
|
self.show_stats = v != StatsVerbosity::Off;
|
||||||
|
}
|
||||||
|
|
||||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||||
pub fn preferred_codec(&self) -> u8 {
|
pub fn preferred_codec(&self) -> u8 {
|
||||||
match self.codec.as_str() {
|
match self.codec.as_str() {
|
||||||
@@ -351,6 +418,7 @@ impl Default for Settings {
|
|||||||
adapter: String::new(),
|
adapter: String::new(),
|
||||||
hdr_enabled: true,
|
hdr_enabled: true,
|
||||||
show_stats: true,
|
show_stats: true,
|
||||||
|
stats_verbosity: None,
|
||||||
fullscreen_on_stream: true,
|
fullscreen_on_stream: true,
|
||||||
library_enabled: false,
|
library_enabled: false,
|
||||||
}
|
}
|
||||||
@@ -432,6 +500,28 @@ mod tests {
|
|||||||
assert!(!s.library_enabled);
|
assert!(!s.library_enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stats-tier resolution: a pre-tier store falls back to `show_stats` (off → Off,
|
||||||
|
/// on/absent → Normal), an explicit tier wins, and setting a tier keeps the legacy
|
||||||
|
/// bool in sync so pre-tier binaries reading the same file agree on off vs on.
|
||||||
|
#[test]
|
||||||
|
fn stats_verbosity_migrates_and_round_trips() {
|
||||||
|
let mut s: Settings = serde_json::from_str("{}").unwrap();
|
||||||
|
assert_eq!(s.stats_verbosity(), StatsVerbosity::Normal);
|
||||||
|
let off: Settings = serde_json::from_str(r#"{"show_stats":false}"#).unwrap();
|
||||||
|
assert_eq!(off.stats_verbosity(), StatsVerbosity::Off);
|
||||||
|
|
||||||
|
s.set_stats_verbosity(StatsVerbosity::Compact);
|
||||||
|
assert!(s.show_stats);
|
||||||
|
s.set_stats_verbosity(StatsVerbosity::Off);
|
||||||
|
assert!(!s.show_stats);
|
||||||
|
|
||||||
|
s.set_stats_verbosity(StatsVerbosity::Detailed);
|
||||||
|
let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||||
|
assert_eq!(round.stats_verbosity(), StatsVerbosity::Detailed);
|
||||||
|
// The tier serializes lowercase — the file stays human-readable.
|
||||||
|
assert!(serde_json::to_string(&s).unwrap().contains("\"detailed\""));
|
||||||
|
}
|
||||||
|
|
||||||
/// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same
|
/// The WinUI shell's known-hosts shape (no `last_used` field) loads losslessly — same
|
||||||
/// filename, same directory, so on Windows the two clients genuinely share the store.
|
/// filename, same directory, so on Windows the two clients genuinely share the store.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::screens::{Ctx, Outbox};
|
|||||||
use crate::theme::{Fonts, DIM, W};
|
use crate::theme::{Fonts, DIM, W};
|
||||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||||
|
use pf_client_core::trust::StatsVerbosity;
|
||||||
use skia_safe::{Canvas, Rect};
|
use skia_safe::{Canvas, Rect};
|
||||||
|
|
||||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||||
@@ -241,7 +242,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
|||||||
RowId::Stats => (
|
RowId::Stats => (
|
||||||
Some("Interface"),
|
Some("Interface"),
|
||||||
"Statistics overlay",
|
"Statistics overlay",
|
||||||
on_off(s.show_stats).into(),
|
s.stats_verbosity().label().into(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
RowSpec {
|
RowSpec {
|
||||||
@@ -274,7 +275,10 @@ fn detail(id: RowId) -> &'static str {
|
|||||||
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
RowId::Mic => "Send this device's microphone to the host's virtual mic.",
|
||||||
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
RowId::Pad => "Which pad is forwarded to the host, as player 1.",
|
||||||
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.",
|
||||||
RowId::Stats => "Resolution, frame rate, throughput and latency while streaming.",
|
RowId::Stats => {
|
||||||
|
"How much the overlay shows: Compact (one line) → Normal → Detailed. \
|
||||||
|
Ctrl+Alt+Shift+S cycles it live while streaming."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +336,13 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
|||||||
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
step_option(cur, keys.len(), delta, wrap).map(|i| s.forward_pad = keys[i].clone())
|
||||||
}
|
}
|
||||||
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
RowId::PadType => step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap),
|
||||||
RowId::Stats => toggle(&mut s.show_stats, delta, wrap),
|
RowId::Stats => {
|
||||||
|
let cur = StatsVerbosity::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|v| *v == s.stats_verbosity());
|
||||||
|
step_option(cur, StatsVerbosity::ALL.len(), delta, wrap)
|
||||||
|
.map(|i| s.set_stats_verbosity(StatsVerbosity::ALL[i]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.is_some()
|
.is_some()
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-14
@@ -8,8 +8,10 @@
|
|||||||
//! library; the app quits only on B/window-close).
|
//! library; the app quits only on B/window-close).
|
||||||
//!
|
//!
|
||||||
//! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}`
|
//! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}`
|
||||||
//! line after the first presented frame, `stats: …` lines once per window while enabled
|
//! line after the first presented frame, `stats: …` lines once per window while the
|
||||||
//! (Ctrl+Alt+Shift+S toggles). Logs go to stderr (the binary configures tracing so).
|
//! overlay tier isn't Off (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed;
|
||||||
|
//! the stdout line always carries the full Detailed text so parsers see a stable
|
||||||
|
//! shape). Logs go to stderr (the binary configures tracing so).
|
||||||
|
|
||||||
use crate::input::Capture;
|
use crate::input::Capture;
|
||||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||||
@@ -17,6 +19,7 @@ use crate::vk::{FrameInput, Presenter};
|
|||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use pf_client_core::gamepad::GamepadService;
|
use pf_client_core::gamepad::GamepadService;
|
||||||
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
||||||
|
use pf_client_core::trust::StatsVerbosity;
|
||||||
use pf_client_core::video::VulkanDecodeDevice;
|
use pf_client_core::video::VulkanDecodeDevice;
|
||||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
@@ -37,8 +40,9 @@ pub struct SessionOpts {
|
|||||||
/// changing content, not a window jumping displays). Fullscreen follows the display
|
/// changing content, not a window jumping displays). Fullscreen follows the display
|
||||||
/// this lands on.
|
/// this lands on.
|
||||||
pub window_pos: Option<(i32, i32)>,
|
pub window_pos: Option<(i32, i32)>,
|
||||||
/// Print `stats:` lines (Ctrl+Alt+Shift+S toggles live).
|
/// Stats overlay tier at start — gates the OSD panel AND the stdout `stats:` lines
|
||||||
pub print_stats: bool,
|
/// (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live).
|
||||||
|
pub stats_verbosity: StatsVerbosity,
|
||||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||||
pub json_status: bool,
|
pub json_status: bool,
|
||||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||||
@@ -162,8 +166,11 @@ struct StreamState {
|
|||||||
// all) demotes the decoder to software via the shared flag — once per session.
|
// all) demotes the decoder to software via the shared flag — once per session.
|
||||||
dmabuf_demoted: bool,
|
dmabuf_demoted: bool,
|
||||||
hw_fails: u32,
|
hw_fails: u32,
|
||||||
/// The OSD's text (multi-line; rebuilt each Stats window).
|
/// The OSD's text (multi-line; rebuilt each Stats window and on a live tier cycle).
|
||||||
osd_text: String,
|
osd_text: String,
|
||||||
|
/// The last pump window, kept so a Ctrl+Alt+Shift+S tier cycle can re-render the
|
||||||
|
/// OSD immediately instead of waiting up to 1 s for the next Stats event.
|
||||||
|
last_stats: Option<Stats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamState {
|
impl StreamState {
|
||||||
@@ -207,6 +214,7 @@ impl StreamState {
|
|||||||
dmabuf_demoted: false,
|
dmabuf_demoted: false,
|
||||||
hw_fails: 0,
|
hw_fails: 0,
|
||||||
osd_text: String::new(),
|
osd_text: String::new(),
|
||||||
|
last_stats: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +359,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
let mouse = sdl.mouse();
|
let mouse = sdl.mouse();
|
||||||
|
|
||||||
let mut fullscreen = opts.fullscreen;
|
let mut fullscreen = opts.fullscreen;
|
||||||
let mut print_stats = opts.print_stats;
|
let mut stats_verbosity = opts.stats_verbosity;
|
||||||
let mut overlay_frame: Option<OverlayFrame> = None;
|
let mut overlay_frame: Option<OverlayFrame> = None;
|
||||||
// SDL text input tracks the overlay's editing state (started = IME/`TextInput`
|
// SDL text input tracks the overlay's editing state (started = IME/`TextInput`
|
||||||
// events on desktop, and the door Steam's on-screen keyboard types through under
|
// events on desktop, and the door Steam's on-screen keyboard types through under
|
||||||
@@ -452,7 +460,24 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if chord && sc == Scancode::S {
|
if chord && sc == Scancode::S {
|
||||||
print_stats = !print_stats;
|
stats_verbosity = stats_verbosity.next();
|
||||||
|
tracing::info!(tier = ?stats_verbosity, "chord: stats verbosity");
|
||||||
|
// Re-render the OSD from the last window immediately — waiting
|
||||||
|
// for the next Stats event would lag the keypress by up to 1 s.
|
||||||
|
if let Some(st) = &mut stream {
|
||||||
|
let text = match &st.last_stats {
|
||||||
|
Some(s) => stats_text(
|
||||||
|
stats_verbosity,
|
||||||
|
&st.mode_line,
|
||||||
|
s,
|
||||||
|
&st.presented,
|
||||||
|
st.hdr,
|
||||||
|
presenter.hdr_active(),
|
||||||
|
),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
st.osd_text = text;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
|
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
|
||||||
@@ -647,15 +672,27 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
}
|
}
|
||||||
SessionEvent::Stats(s) => {
|
SessionEvent::Stats(s) => {
|
||||||
st.osd_text = stats_text(
|
st.osd_text = stats_text(
|
||||||
|
stats_verbosity,
|
||||||
&st.mode_line,
|
&st.mode_line,
|
||||||
&s,
|
&s,
|
||||||
&st.presented,
|
&st.presented,
|
||||||
st.hdr,
|
st.hdr,
|
||||||
presenter.hdr_active(),
|
presenter.hdr_active(),
|
||||||
);
|
);
|
||||||
if print_stats {
|
if stats_verbosity != StatsVerbosity::Off {
|
||||||
println!("stats: {}", st.osd_text.replace('\n', " | "));
|
// The stdout line is the machine interface (shell status card,
|
||||||
|
// scripts) — always the full Detailed text, whatever the OSD tier.
|
||||||
|
let full = stats_text(
|
||||||
|
StatsVerbosity::Detailed,
|
||||||
|
&st.mode_line,
|
||||||
|
&s,
|
||||||
|
&st.presented,
|
||||||
|
st.hdr,
|
||||||
|
presenter.hdr_active(),
|
||||||
|
);
|
||||||
|
println!("stats: {}", full.replace('\n', " | "));
|
||||||
}
|
}
|
||||||
|
st.last_stats = Some(s);
|
||||||
}
|
}
|
||||||
SessionEvent::Failed {
|
SessionEvent::Failed {
|
||||||
msg,
|
msg,
|
||||||
@@ -728,7 +765,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
(print_stats && !st.osd_text.is_empty()).then_some(st.osd_text.as_str()),
|
(stats_verbosity != StatsVerbosity::Off && !st.osd_text.is_empty())
|
||||||
|
.then_some(st.osd_text.as_str()),
|
||||||
hint,
|
hint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -996,20 +1034,43 @@ const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift
|
|||||||
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \
|
||||||
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
|
Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";
|
||||||
|
|
||||||
/// The unified stats window (design/stats-unification.md) as OSD text — multi-line for
|
/// The unified stats window (design/stats-unification.md) as OSD text at the given tier
|
||||||
/// the console-UI panel; the stdout `stats:` line joins it with `|`.
|
/// (the Android client's vocabulary, each a strict superset of the previous):
|
||||||
|
/// Compact = one glanceable line, Normal = mode + end-to-end percentiles + loss,
|
||||||
|
/// Detailed = decoder path, HDR tag and the per-stage equation on top. Off reads empty.
|
||||||
|
/// Multi-line for the console-UI panel; the stdout `stats:` line joins Detailed with `|`.
|
||||||
///
|
///
|
||||||
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
|
/// The HDR tag is honest about the display path: `HDR` only when the swapchain actually
|
||||||
/// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10
|
/// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10
|
||||||
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
|
/// format offered, HDR off in the compositor) shows `HDR→SDR` instead.
|
||||||
fn stats_text(
|
fn stats_text(
|
||||||
|
verbosity: StatsVerbosity,
|
||||||
mode_line: &str,
|
mode_line: &str,
|
||||||
s: &Stats,
|
s: &Stats,
|
||||||
p: &PresentedWindow,
|
p: &PresentedWindow,
|
||||||
hdr_stream: bool,
|
hdr_stream: bool,
|
||||||
hdr_display: bool,
|
hdr_display: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut text = format!(
|
match verbosity {
|
||||||
|
StatsVerbosity::Off => return String::new(),
|
||||||
|
StatsVerbosity::Compact => {
|
||||||
|
// fps · e2e ms · Mb/s — the latency term waits for the first presenter
|
||||||
|
// window (0 = no capture→displayed samples yet).
|
||||||
|
let mut text = format!("{:.0} fps", s.fps);
|
||||||
|
if p.e2e_p50_ms > 0.0 {
|
||||||
|
text.push_str(&format!(" · {:.1} ms", p.e2e_p50_ms));
|
||||||
|
}
|
||||||
|
text.push_str(&format!(" · {:.0} Mb/s", s.mbps));
|
||||||
|
if s.lost > 0 {
|
||||||
|
text.push_str(&format!(" · lost {}", s.lost));
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
StatsVerbosity::Normal | StatsVerbosity::Detailed => {}
|
||||||
|
}
|
||||||
|
let detailed = verbosity == StatsVerbosity::Detailed;
|
||||||
|
let mut text = if detailed {
|
||||||
|
format!(
|
||||||
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
|
"{mode_line} · {:.0} fps · {:.1} Mb/s · {}{}",
|
||||||
s.fps,
|
s.fps,
|
||||||
s.mbps,
|
s.mbps,
|
||||||
@@ -1019,11 +1080,15 @@ fn stats_text(
|
|||||||
(true, false) => " · HDR→SDR",
|
(true, false) => " · HDR→SDR",
|
||||||
_ => "",
|
_ => "",
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
} else {
|
||||||
|
format!("{mode_line} · {:.0} fps · {:.1} Mb/s", s.fps, s.mbps)
|
||||||
|
};
|
||||||
text.push_str(&format!(
|
text.push_str(&format!(
|
||||||
"\ne2e {:.1}/{:.1} ms (p50/p95)",
|
"\ne2e {:.1}/{:.1} ms (p50/p95)",
|
||||||
p.e2e_p50_ms, p.e2e_p95_ms
|
p.e2e_p50_ms, p.e2e_p95_ms
|
||||||
));
|
));
|
||||||
|
if detailed {
|
||||||
if s.split {
|
if s.split {
|
||||||
text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms));
|
text.push_str(&format!(" · host {:.1} · net {:.1}", s.host_ms, s.net_ms));
|
||||||
} else {
|
} else {
|
||||||
@@ -1033,8 +1098,74 @@ fn stats_text(
|
|||||||
" · decode {:.1} · display {:.1} ms",
|
" · decode {:.1} · display {:.1} ms",
|
||||||
s.decode_ms, p.display_ms
|
s.decode_ms, p.display_ms
|
||||||
));
|
));
|
||||||
|
}
|
||||||
if s.lost > 0 {
|
if s.lost > 0 {
|
||||||
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
|
text.push_str(&format!("\nlost {} ({:.1}%)", s.lost, s.lost_pct));
|
||||||
}
|
}
|
||||||
text
|
text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample() -> (Stats, PresentedWindow) {
|
||||||
|
(
|
||||||
|
Stats {
|
||||||
|
fps: 119.6,
|
||||||
|
mbps: 24.3,
|
||||||
|
host_net_ms: 2.1,
|
||||||
|
host_ms: 1.2,
|
||||||
|
net_ms: 0.9,
|
||||||
|
split: true,
|
||||||
|
decode_ms: 1.8,
|
||||||
|
lost: 3,
|
||||||
|
lost_pct: 0.4,
|
||||||
|
decoder: "vulkan",
|
||||||
|
},
|
||||||
|
PresentedWindow {
|
||||||
|
e2e_p50_ms: 6.4,
|
||||||
|
e2e_p95_ms: 9.1,
|
||||||
|
display_ms: 1.1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tier ladder: Off is empty, Compact is one line, Normal adds the mode + e2e
|
||||||
|
/// lines but no stage terms or decoder tag, Detailed carries everything.
|
||||||
|
#[test]
|
||||||
|
fn stats_text_tiers() {
|
||||||
|
let (s, p) = sample();
|
||||||
|
let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false);
|
||||||
|
|
||||||
|
assert_eq!(text(StatsVerbosity::Off), "");
|
||||||
|
|
||||||
|
let compact = text(StatsVerbosity::Compact);
|
||||||
|
assert_eq!(compact, "120 fps · 6.4 ms · 24 Mb/s · lost 3");
|
||||||
|
assert_eq!(compact.lines().count(), 1);
|
||||||
|
|
||||||
|
let normal = text(StatsVerbosity::Normal);
|
||||||
|
assert!(normal.starts_with("1920×1080@120 · 120 fps · 24.3 Mb/s\n"));
|
||||||
|
assert!(normal.contains("e2e 6.4/9.1 ms (p50/p95)"));
|
||||||
|
assert!(normal.contains("lost 3 (0.4%)"));
|
||||||
|
assert!(!normal.contains("vulkan"), "decoder tag is Detailed-only");
|
||||||
|
assert!(!normal.contains("decode"), "stage terms are Detailed-only");
|
||||||
|
|
||||||
|
let detailed = text(StatsVerbosity::Detailed);
|
||||||
|
assert!(detailed.contains("vulkan · HDR→SDR"));
|
||||||
|
assert!(detailed.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms"));
|
||||||
|
assert!(detailed.contains("lost 3 (0.4%)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact omits the latency term until the presenter's first e2e window lands.
|
||||||
|
#[test]
|
||||||
|
fn compact_waits_for_e2e() {
|
||||||
|
let (mut s, _) = sample();
|
||||||
|
s.lost = 0;
|
||||||
|
let p = PresentedWindow::default();
|
||||||
|
assert_eq!(
|
||||||
|
stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false),
|
||||||
|
"120 fps · 24 Mb/s"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user