feat(apple): stage-2 default + pixel-perfect, decode robustness, UI/rumble polish
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
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>
This commit is contained in:
@@ -17,10 +17,10 @@ struct AcknowledgementsView: View {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("punktfunk")
|
||||
.font(.title2).bold()
|
||||
.font(.geist(22, .bold, relativeTo: .title2))
|
||||
if let version {
|
||||
Text("Version \(version)")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(Licenses.appLicense)
|
||||
@@ -29,14 +29,29 @@ struct AcknowledgementsView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Bundled font")
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text("punktfunk ships the Geist typeface (Geist Sans), "
|
||||
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
||||
+ "License 1.1.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
if !Licenses.fontLicense.isEmpty {
|
||||
Text(Licenses.fontLicense)
|
||||
.font(.caption2.monospaced())
|
||||
.modifier(SelectableText())
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Third-party software")
|
||||
.font(.headline)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(
|
||||
"punktfunk uses the open-source components below, each under its own license. "
|
||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||
+ "(dynamically linked, replaceable)."
|
||||
)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// App-wide brand chrome. SwiftUI has no single switch to put a custom font on every navigation
|
||||
// title, so the iOS large/inline nav titles are themed through UINavigationBar's appearance proxy
|
||||
// (set once at launch). Backgrounds are left at the system defaults — transparent at the scroll
|
||||
// edge (the large title floats on the content), blurred once scrolled — so only the typeface
|
||||
// changes: Geist, matching the cards and the website.
|
||||
|
||||
#if os(iOS)
|
||||
import PunktfunkKit
|
||||
import UIKit
|
||||
|
||||
enum BrandTheme {
|
||||
static func apply() {
|
||||
BrandFont.registerIfNeeded()
|
||||
|
||||
let scrollEdge = UINavigationBarAppearance()
|
||||
scrollEdge.configureWithTransparentBackground()
|
||||
applyFonts(to: scrollEdge)
|
||||
|
||||
let standard = UINavigationBarAppearance()
|
||||
standard.configureWithDefaultBackground()
|
||||
applyFonts(to: standard)
|
||||
|
||||
let proxy = UINavigationBar.appearance()
|
||||
proxy.scrollEdgeAppearance = scrollEdge
|
||||
proxy.standardAppearance = standard
|
||||
proxy.compactAppearance = standard
|
||||
}
|
||||
|
||||
/// Override only the title fonts; leave colors/backgrounds at the configured defaults.
|
||||
private static func applyFonts(to appearance: UINavigationBarAppearance) {
|
||||
if let large = UIFont(name: "Geist-Bold", size: 34) {
|
||||
appearance.largeTitleTextAttributes[.font] = large
|
||||
}
|
||||
if let inline = UIFont(name: "Geist-SemiBold", size: 17) {
|
||||
appearance.titleTextAttributes[.font] = inline
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -28,6 +28,7 @@ struct ContentView: View {
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@@ -68,15 +69,19 @@ struct ContentView: View {
|
||||
// A session actually started — remember it on the card ("Connected … ago"
|
||||
// plus the accent ring on the most recent host).
|
||||
guard let host = model.activeHost else { break }
|
||||
store.markConnected(host.id)
|
||||
// Delegated approval just succeeded: the operator let this device in, so pin the
|
||||
// host's observed fingerprint and remember it as paired — future connects are then
|
||||
// silent (rule 1), exactly like after a PIN/TOFU success. Dismisses the wait prompt.
|
||||
if awaitingApproval?.host.id == host.id {
|
||||
if let fp = model.connection?.hostFingerprint {
|
||||
store.pin(host.id, fingerprint: fp)
|
||||
}
|
||||
awaitingApproval = nil
|
||||
let approvedFingerprint = awaitingApproval?.host.id == host.id
|
||||
? model.connection?.hostFingerprint : nil
|
||||
if awaitingApproval?.host.id == host.id { awaitingApproval = nil }
|
||||
// Persist on the next runloop tick: HostStore is an ObservableObject, and mutating
|
||||
// its @Published from inside .onChange (a view-update callback) trips SwiftUI's
|
||||
// "Publishing changes from within view updates". A one-tick delay is imperceptible.
|
||||
let store = store
|
||||
DispatchQueue.main.async {
|
||||
store.markConnected(host.id)
|
||||
if let approvedFingerprint { store.pin(host.id, fingerprint: approvedFingerprint) }
|
||||
}
|
||||
case .idle:
|
||||
// The delegated-approval connect failed, timed out, or was cancelled — drop the
|
||||
@@ -333,6 +338,7 @@ struct ContentView: View {
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||
bitrateKbps: UInt32(clamping: bitrateKbps),
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
hdrEnabled: hdrEnabled,
|
||||
launchID: launchID,
|
||||
allowTofu: allowTofu,
|
||||
requestAccess: requestAccess)
|
||||
@@ -475,6 +481,7 @@ struct ContentView: View {
|
||||
gamepad: pad,
|
||||
bitrateKbps: bitrate,
|
||||
audioChannels: UInt8(clamping: audioChannels),
|
||||
hdrEnabled: hdrEnabled,
|
||||
autoTrust: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ struct ControllerTestView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Test Controller").font(.headline)
|
||||
Text("Test Controller").font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
@@ -99,8 +99,8 @@ struct ControllerTestView: View {
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(c.name).font(.headline)
|
||||
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
|
||||
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
|
||||
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -209,7 +209,7 @@ struct ControllerTestView: View {
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||
fingerDot(tp.primary, color: .accentColor)
|
||||
@@ -230,7 +230,7 @@ struct ControllerTestView: View {
|
||||
private func motionReadout(_ m: GCMotion) -> some View {
|
||||
let a = Self.totalAccel(m)
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Motion").font(.caption2).foregroundStyle(.secondary)
|
||||
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||
.font(.caption2.monospaced())
|
||||
@@ -254,11 +254,11 @@ struct ControllerTestView: View {
|
||||
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||
Toggle("Light motor (right)", isOn: $lightOn)
|
||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||
+ "can't reach its motors on macOS).")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||
.onChange(of: lightOn) { _, _ in applyRumble() }
|
||||
@@ -289,11 +289,11 @@ struct ControllerTestView: View {
|
||||
}
|
||||
}
|
||||
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Adaptive triggers need a DualSense.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,7 +348,7 @@ struct ControllerTestView: View {
|
||||
_ title: String, @ViewBuilder _ content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title).font(.subheadline.weight(.semibold))
|
||||
Text(title).font(.geist(15, .semibold, relativeTo: .subheadline))
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -127,14 +127,13 @@ struct HomeView: View {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
#if os(iOS)
|
||||
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
|
||||
// is presented directly — wrapping it in a NavigationStack here would nest a split view in
|
||||
// a stack (double title bars). `settingsSheetSizing()` widens the sheet on iPad for the
|
||||
// two-column layout.
|
||||
.sheet(isPresented: $showSettings) {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
Button("Done") { showSettings = false }
|
||||
}
|
||||
}
|
||||
SettingsView()
|
||||
.settingsSheetSizing()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -172,7 +171,7 @@ struct HomeView: View {
|
||||
private var discoveredSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.headline)
|
||||
.font(.geist(15, .semibold, relativeTo: .headline))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
@@ -249,8 +248,10 @@ struct HomeView: View {
|
||||
/// the width so the cards stay edge-aligned with the title and bars — sized touch-first: one
|
||||
/// column on iPhone portrait, 3–4 generous cards on iPad.
|
||||
private var gridColumns: [GridItem] {
|
||||
// Wider than before: the monogram card is a horizontal module (tile + address line), so
|
||||
// it needs room for a monospaced "IP:port" without truncating.
|
||||
#if os(macOS)
|
||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
||||
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||
#elseif os(tvOS)
|
||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||
#else
|
||||
|
||||
@@ -1,26 +1,75 @@
|
||||
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
||||
// host (tap to save + connect). Both share the same platform-tuned sizing.
|
||||
// host (tap to save + connect). Both share the "monogram module" look — a squared brand-purple
|
||||
// monogram tile + a left-aligned bold Geist name over monospaced technical metadata
|
||||
// (address, status), framed by a hairline panel border. Industrial, not soft.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
||||
/// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS.
|
||||
private struct CardMetrics {
|
||||
let iconSize: CGFloat
|
||||
let iconBox: CGFloat
|
||||
let cardPadding: CGFloat
|
||||
let nameFont: Font
|
||||
let tile: CGFloat // monogram tile side
|
||||
let monogram: CGFloat // monogram letter point size
|
||||
let name: CGFloat // host-name point size
|
||||
let meta: CGFloat // address (mono) point size
|
||||
let status: CGFloat // status-label (mono) point size
|
||||
let padding: CGFloat
|
||||
let spacing: CGFloat // tile ↔ text gap
|
||||
let radius: CGFloat
|
||||
|
||||
static var current: CardMetrics {
|
||||
#if os(iOS)
|
||||
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
||||
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||
padding: 16, spacing: 14, radius: 12)
|
||||
#elseif os(tvOS)
|
||||
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
||||
padding: 18, spacing: 18, radius: 14)
|
||||
#else
|
||||
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
||||
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||
padding: 13, spacing: 12, radius: 10)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
||||
/// First letter of a host name, uppercased — the monogram glyph. Falls back to a bullet.
|
||||
private func monogram(_ name: String) -> String {
|
||||
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
|
||||
return String(first).uppercased()
|
||||
}
|
||||
|
||||
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
|
||||
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
|
||||
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
|
||||
return ZStack {
|
||||
shape.fill(filled
|
||||
? AnyShapeStyle(LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.72)],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
: AnyShapeStyle(Color.brand.opacity(0.14)))
|
||||
if connecting {
|
||||
ProgressView().tint(filled ? .white : Color.brand)
|
||||
} else {
|
||||
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
|
||||
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
|
||||
// the clip below are belt-and-suspenders for an unusually wide glyph.
|
||||
Text(letter)
|
||||
.font(.geistFixed(m.monogram, .bold))
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(filled ? Color.white : Color.brand)
|
||||
}
|
||||
}
|
||||
.frame(width: m.tile, height: m.tile)
|
||||
.clipShape(shape)
|
||||
.overlay {
|
||||
if !filled {
|
||||
shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
|
||||
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||
struct HostCardView: View {
|
||||
let host: StoredHost
|
||||
@@ -41,66 +90,44 @@ struct HostCardView: View {
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
VStack(spacing: 10) {
|
||||
ZStack {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: m.iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.opacity(isConnecting ? 0.3 : 1)
|
||||
if isConnecting {
|
||||
ProgressView()
|
||||
}
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(host.displayName)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
statusRow(m)
|
||||
}
|
||||
.frame(height: m.iconBox)
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
// Presence dot: green = advertising on the LAN now; grey = not seen.
|
||||
Circle()
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.35))
|
||||
.frame(width: 7, height: 7)
|
||||
.accessibilityLabel(isOnline ? "Online" : "Offline")
|
||||
Text(host.displayName)
|
||||
.font(m.nameFont)
|
||||
.lineLimit(1)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
if host.pinnedSHA256 != nil {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("\(host.address):\(String(host.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let last = host.lastConnected {
|
||||
Text("Connected \(last, format: .relative(presentation: .named))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
|
||||
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
|
||||
// and a brand accent bar down the leading edge for the most-recent host.
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .leading) {
|
||||
if isMostRecent {
|
||||
Rectangle().fill(Color.brand).frame(width: 3)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, m.cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
||||
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
||||
// Deliberately .regularMaterial, not Liquid Glass: HIG keeps glass off content
|
||||
// tiles (it flattens hierarchy over an opaque grid) — see GlassStyle.swift.
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
if isMostRecent {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
||||
}
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
@@ -119,10 +146,31 @@ struct HostCardView: View {
|
||||
Button("Remove", role: .destructive, action: onRemove)
|
||||
}
|
||||
}
|
||||
|
||||
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
|
||||
/// certificate is pinned (the lock state, spelled out).
|
||||
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
|
||||
.frame(width: 6, height: 6)
|
||||
// The state is spelled out in the adjacent text, so the pip is decorative —
|
||||
// otherwise VoiceOver reads the status twice ("Online, ONLINE …").
|
||||
.accessibilityHidden(true)
|
||||
Text(isOnline ? "ONLINE" : "OFFLINE")
|
||||
if host.pinnedSHA256 != nil {
|
||||
Text("· PAIRED")
|
||||
}
|
||||
}
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
||||
/// tapping saves it and connects (or pairs, if the host requires it).
|
||||
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
||||
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||
struct DiscoveredCardView: View {
|
||||
let discovered: DiscoveredHost
|
||||
let isBusy: Bool
|
||||
@@ -131,47 +179,77 @@ struct DiscoveredCardView: View {
|
||||
var body: some View {
|
||||
let m = CardMetrics.current
|
||||
return Button(action: onConnect) {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: m.iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(height: m.iconBox)
|
||||
VStack(spacing: 2) {
|
||||
HStack(spacing: m.spacing) {
|
||||
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(discovered.name)
|
||||
.font(m.nameFont)
|
||||
.font(.geist(m.name, .bold, relativeTo: .title3))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text("\(discovered.host):\(String(discovered.port))")
|
||||
.font(.geist(m.meta, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: discovered.requiresPairing
|
||||
? "lock.fill" : "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: m.status))
|
||||
.accessibilityHidden(true) // decorative; the adjacent text says the state
|
||||
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
|
||||
}
|
||||
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.geist(m.status, .medium, relativeTo: .caption2))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, m.cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(m.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if !os(tvOS)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.secondary.opacity(0.25),
|
||||
Color.secondary.opacity(0.3),
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#elseif os(iOS)
|
||||
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.disabled(isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The iOS host-card press/hover treatment, one style for both idioms:
|
||||
/// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is
|
||||
/// inert without a pointer.)
|
||||
/// - iPad: the system pointer "magnet" — the cursor morphs into a highlight that conforms to the
|
||||
/// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the
|
||||
/// press scale doubles as click feedback.)
|
||||
struct HostCardButtonStyle: ButtonStyle {
|
||||
var cornerRadius: CGFloat
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed)
|
||||
// Conform the pointer highlight to the card's rounded rect, not its square bounds.
|
||||
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.hoverEffect(.highlight)
|
||||
// Light tap on press-down (nil on release so it fires once, on touch). No haptic
|
||||
// hardware on iPad → silently ignored there.
|
||||
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
|
||||
pressed ? .impact(weight: .light) : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -146,7 +146,7 @@ private struct GameCard: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(alignment: .topLeading) { storeBadge }
|
||||
Text(game.title)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ private struct GameCard: View {
|
||||
|
||||
private var storeBadge: some View {
|
||||
Text(game.isCustom ? "Custom" : "Steam")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
@@ -193,7 +193,7 @@ private struct PosterImage: View {
|
||||
ZStack {
|
||||
Rectangle().fill(.quaternary)
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
|
||||
@@ -48,7 +48,7 @@ struct PairSheet: View {
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||
+ "needed.")
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
TVFieldRow(
|
||||
@@ -59,7 +59,7 @@ struct PairSheet: View {
|
||||
) { editing = .clientName }
|
||||
if let errorText {
|
||||
Text(errorText)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
HStack(spacing: 32) {
|
||||
@@ -121,13 +121,13 @@ struct PairSheet: View {
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint "
|
||||
+ "comparison needed.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let errorText {
|
||||
Section {
|
||||
Text(errorText)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,36 @@ struct PunktfunkClientApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
#endif
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
// Put Geist on the navigation titles before any bar is built.
|
||||
BrandTheme.apply()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Punktfunk") {
|
||||
#if DEBUG
|
||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||
// the whole path is absent from Release builds.
|
||||
if let scene = ScreenshotMode.requestedScene {
|
||||
ScreenshotHostView(scene: scene)
|
||||
} else {
|
||||
// Pin the whole app's tint to the brand purple explicitly — the asset-catalog accent
|
||||
// resolution is environment/timing-sensitive and can fall back to system blue. Wraps the
|
||||
// screenshot harness too, so captured screens are on-brand.
|
||||
Group {
|
||||
#if DEBUG
|
||||
// PUNKTFUNK_SHOT_SCENE=<name> → show that single mock-populated screen full-bleed for
|
||||
// the App Store screenshot capture (tools/screenshots.sh). Normal launch otherwise;
|
||||
// the whole path is absent from Release builds.
|
||||
if let scene = ScreenshotMode.requestedScene {
|
||||
ScreenshotHostView(scene: scene)
|
||||
} else {
|
||||
ContentView()
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
.tint(.brand)
|
||||
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
||||
// form row labels; views that pick an explicit size/weight use `.geist(…)` directly.
|
||||
.font(.geist(17, relativeTo: .body))
|
||||
}
|
||||
// The Stream menu (Disconnect ⌘D, Show/Hide Statistics ⌘⇧S) — a real menu bar on
|
||||
// macOS, hardware-keyboard shortcuts on iPad. tvOS has neither.
|
||||
@@ -34,7 +50,10 @@ struct PunktfunkClientApp: App {
|
||||
#endif
|
||||
#if os(macOS)
|
||||
Settings {
|
||||
// A separate scene — `.tint` does not cross scene boundaries, so re-apply the brand
|
||||
// tint here or the Preferences window falls back to the (unreliable) asset accent.
|
||||
SettingsView()
|
||||
.tint(.brand)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -103,11 +103,11 @@ private struct ShotSettings: View {
|
||||
.shadow(radius: 40, y: 16)
|
||||
}
|
||||
#elseif os(iOS)
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
// SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is
|
||||
// rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open
|
||||
// on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone:
|
||||
// the General page) instead of the bare category list.
|
||||
SettingsView(initialCategory: .general)
|
||||
#else
|
||||
NavigationStack { SettingsView() }
|
||||
#endif
|
||||
@@ -175,10 +175,10 @@ private struct ShotHUD: View {
|
||||
.foregroundStyle(.secondary)
|
||||
#if os(macOS)
|
||||
Text("⌘⎋ releases the mouse")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
|
||||
#elseif os(tvOS)
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
@@ -259,7 +259,7 @@ private struct ShotDesktopFrame: View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
Text("Streaming from Battlestation")
|
||||
.font(.system(.callout, weight: .semibold))
|
||||
.font(.geist(16, .semibold, relativeTo: .callout))
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 9)
|
||||
.glassBackground(Capsule())
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// App settings. The host creates a native virtual output at exactly the chosen size/refresh —
|
||||
// there is no scaling anywhere in the pipeline.
|
||||
//
|
||||
// Navigation differs per platform: macOS uses a tabbed preferences window (the sections had
|
||||
// outgrown one scrolling pane); iOS uses a single grouped Form; tvOS uses a focus-native
|
||||
// pushed-picker layout. The individual sections (`streamModeSection`, `audioSection`, …) are
|
||||
// shared across all three so a setting is defined exactly once.
|
||||
// Navigation differs per platform, but all three group the same categories (General, Display,
|
||||
// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses
|
||||
// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to
|
||||
// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a
|
||||
// focus-native pushed-picker layout. The individual sections (`streamModeSection`,
|
||||
// `audioSection`, …) are shared across all three so a setting is defined exactly once.
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -21,7 +23,8 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
|
||||
@AppStorage(DefaultsKey.presenter) private var presenter = "stage2"
|
||||
@AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||
@@ -32,6 +35,21 @@ struct SettingsView: View {
|
||||
#if DEBUG && !os(tvOS)
|
||||
@State private var showControllerTest = false
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone.
|
||||
// Width class decides the initial value: nil on iPhone (show the category list first),
|
||||
// General on iPad (a two-column layout should never open with an empty detail).
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var settingsSelection: SettingsCategory?
|
||||
// Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen
|
||||
// — not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts
|
||||
// .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible.
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
|
||||
// Sticky once the wheel lands on "Custom…", so editing a width/height that briefly equals a
|
||||
// preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even
|
||||
// when this is false (see `isCustomResolution`), so it survives relaunches without persisting.
|
||||
@State private var customMode = false
|
||||
#endif
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||
@@ -39,6 +57,15 @@ struct SettingsView: View {
|
||||
@State private var inputDevices: [AudioDevice] = []
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on
|
||||
/// General via `onAppear`). The screenshot harness passes an explicit category so the captured
|
||||
/// shot opens on a real settings page (a populated detail) rather than the bare category list.
|
||||
init(initialCategory: SettingsCategory? = nil) {
|
||||
_settingsSelection = State(initialValue: initialCategory)
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
// Native tv pattern: no inline text entry (typing numbers with a remote is
|
||||
@@ -66,6 +93,7 @@ struct SettingsView: View {
|
||||
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
windowSection
|
||||
statisticsSection
|
||||
}
|
||||
@@ -106,29 +134,115 @@ struct SettingsView: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - iOS: one grouped Form
|
||||
// MARK: - iOS / iPadOS: adaptive split view
|
||||
|
||||
#if os(iOS)
|
||||
private var iosBody: some View {
|
||||
Form {
|
||||
streamModeSection
|
||||
audioSection
|
||||
compositorSection
|
||||
presenterSection
|
||||
statisticsSection
|
||||
experimentalSection
|
||||
controllersSection
|
||||
Section {
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
List(selection: $settingsSelection) {
|
||||
ForEach(SettingsCategory.allCases) { category in
|
||||
// On iPhone the split view collapses to a push list, but a selection List
|
||||
// draws no disclosure indicator of its own — add one in compact width for the
|
||||
// expected drill-in affordance. On iPad the selected row highlights instead, so
|
||||
// the chevron is omitted there.
|
||||
HStack {
|
||||
Label(category.title, systemImage: category.symbol)
|
||||
if horizontalSizeClass == .compact {
|
||||
Spacer()
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
// Purely a drill-in affordance — the row's button trait already
|
||||
// conveys "opens"; keep it out of the VoiceOver announcement.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.tag(category)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
// NavigationSplitView hosts the detail in its own navigation context (its title bar),
|
||||
// so no inner NavigationStack — that would double the bar on iPad. On iPhone the split
|
||||
// view collapses to one stack and pushes this when a row is tapped. `?? .general` only
|
||||
// backs the brief pre-selection window; the list never auto-pushes on a nil selection.
|
||||
settingsDetail(settingsSelection ?? .general)
|
||||
// Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the
|
||||
// iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the
|
||||
// sidebar is showing, its Done is the only one — so this stays hidden to avoid two.
|
||||
.toolbar {
|
||||
if horizontalSizeClass == .compact || columnVisibility == .detailOnly {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onAppear {
|
||||
if horizontalSizeClass == .regular, settingsSelection == nil {
|
||||
settingsSelection = .general
|
||||
}
|
||||
gamepads.refresh()
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
// A regular→regular launch sets the default above; this catches a compact→regular change
|
||||
// (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in.
|
||||
.onChange(of: horizontalSizeClass) { _, newValue in
|
||||
if newValue == .regular, settingsSelection == nil {
|
||||
settingsSelection = .general
|
||||
}
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsDetail(_ category: SettingsCategory) -> some View {
|
||||
switch category {
|
||||
case .general:
|
||||
Form {
|
||||
streamModeSection
|
||||
compositorSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("General")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .display:
|
||||
Form {
|
||||
presenterSection
|
||||
hdrSection
|
||||
statisticsSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Display")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .audio:
|
||||
Form { audioSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Audio")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .controllers:
|
||||
Form { controllersSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Controllers")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .advanced:
|
||||
Form { experimentalSection }
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Advanced")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .about:
|
||||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||||
// the large title from the "Settings" sidebar root).
|
||||
AcknowledgementsView()
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - tvOS
|
||||
@@ -156,6 +270,10 @@ struct SettingsView: View {
|
||||
Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var hdrEnabledTag: Binding<String> {
|
||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||
}
|
||||
|
||||
private var tvBody: some View {
|
||||
let currentTag = "\(width)x\(height)x\(hz)"
|
||||
let bounds = UIScreen.main.nativeBounds
|
||||
@@ -186,20 +304,25 @@ struct SettingsView: View {
|
||||
selection: $audioChannels)
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
TVSelectionRow(
|
||||
title: "Compositor", options: compositors, selection: $compositor)
|
||||
#if DEBUG
|
||||
TVSelectionRow(
|
||||
title: "Presenter",
|
||||
options: [("Stage 1 (default)", "stage1"), ("Stage 2 (experimental)", "stage2")],
|
||||
title: "Presenter (debug)",
|
||||
options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")],
|
||||
selection: $presenter)
|
||||
#endif
|
||||
TVSelectionRow(
|
||||
title: "10-bit HDR",
|
||||
options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag)
|
||||
Text("The host creates a virtual output at exactly this mode — native "
|
||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||
+ "is honored only if available on the host.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
@@ -219,7 +342,7 @@ struct SettingsView: View {
|
||||
TVSelectionRow(
|
||||
title: "Controller type", options: Self.padTypes, selection: $gamepadType)
|
||||
Text(Self.controllersFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
@@ -243,6 +366,63 @@ struct SettingsView: View {
|
||||
|
||||
@ViewBuilder private var streamModeSection: some View {
|
||||
Section {
|
||||
#if os(iOS)
|
||||
// Touch-first: a rotating wheel of common resolutions (this device's own mode first) and
|
||||
// a segmented refresh-rate control — the same family as the Clock/Timer pickers. The host
|
||||
// renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The
|
||||
// last wheel row, "Custom…", reveals width/height/refresh fields for an arbitrary mode.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Resolution")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Resolution", selection: resolutionSelection) {
|
||||
ForEach(resolutionChoices, id: \.tag) { choice in
|
||||
Text(choice.label).tag(choice.tag)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxHeight: 140)
|
||||
}
|
||||
if isCustomResolution {
|
||||
// Arbitrary entry: type the exact width × height (and refresh) the host should drive.
|
||||
HStack {
|
||||
TextField("Width", value: $width, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
Text("×")
|
||||
TextField("Height", value: $height, format: .number.grouping(.never))
|
||||
.labelsHidden()
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
// A row built from an HStack of TextFields otherwise insets its bottom separator to
|
||||
// the inner content, clipping the hairline under "Width"; pin it to the cell edge.
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
|
||||
LabeledContent("Refresh rate") {
|
||||
TextField("Hz", value: $hz, format: .number.grouping(.never))
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if refreshChoices.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Refresh rate")
|
||||
.font(.geist(15, relativeTo: .subheadline))
|
||||
.foregroundStyle(.secondary)
|
||||
Picker("Refresh rate", selection: $hz) {
|
||||
ForEach(refreshChoices, id: \.self) { rate in
|
||||
Text("\(rate) Hz").tag(rate)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
} else {
|
||||
// A device with a single supported rate (e.g. 60 Hz) has nothing to pick.
|
||||
LabeledContent("Refresh rate") {
|
||||
Text("\(hz) Hz").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
#elseif os(macOS)
|
||||
HStack {
|
||||
TextField("Resolution", value: $width, format: .number.grouping(.never))
|
||||
Text("×")
|
||||
@@ -253,6 +433,7 @@ struct SettingsView: View {
|
||||
LabeledContent("") {
|
||||
Button("Use this display's mode") { fillFromMainScreen() }
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
Toggle("Automatic bitrate", isOn: automaticBitrate)
|
||||
if bitrateKbps != 0 {
|
||||
@@ -267,7 +448,7 @@ struct SettingsView: View {
|
||||
}
|
||||
if bitrateKbps > 1_000_000 {
|
||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
@@ -277,11 +458,85 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("The host creates a virtual output at exactly this mode — "
|
||||
+ "native resolution, no scaling. \(Self.bitrateFooter)")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: - Stream mode (iOS wheel)
|
||||
|
||||
/// Sentinel wheel tag for the "Custom…" row. Real tags are "WxH" (digits + "x"), so this can't
|
||||
/// collide with a resolution.
|
||||
private static let customResolutionTag = "custom"
|
||||
|
||||
/// 16:9 then ultrawide presets; the device's native mode is prepended at runtime.
|
||||
private static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
||||
("720p", 1280, 720),
|
||||
("1080p", 1920, 1080),
|
||||
("1440p", 2560, 1440),
|
||||
("4K", 3840, 2160),
|
||||
("Ultrawide 1080p", 2560, 1080),
|
||||
("Ultrawide 1440p", 3440, 1440),
|
||||
("Super ultrawide", 5120, 1440),
|
||||
]
|
||||
|
||||
/// The non-custom wheel rows: this device's native mode first, then the presets, deduped by
|
||||
/// dimensions (native wins a tie).
|
||||
private var resolutionModes: [(name: String, w: Int, h: Int)] {
|
||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
||||
let native = (w: Int(max(bounds.width, bounds.height)), h: Int(min(bounds.width, bounds.height)))
|
||||
let all = [(name: "This device", w: native.w, h: native.h)] + Self.resolutionPresets
|
||||
var seen = Set<String>()
|
||||
return all.filter { seen.insert("\($0.w)x\($0.h)").inserted }
|
||||
}
|
||||
|
||||
/// Wheel rows: the resolution modes, then a "Custom…" row that reveals the numeric fields.
|
||||
private var resolutionChoices: [(label: String, tag: String)] {
|
||||
resolutionModes.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") }
|
||||
+ [(label: "Custom…", tag: Self.customResolutionTag)]
|
||||
}
|
||||
|
||||
private var presetResolutionTags: Set<String> {
|
||||
Set(resolutionModes.map { "\($0.w)x\($0.h)" })
|
||||
}
|
||||
|
||||
/// True when the editable custom fields should show: the wheel is parked on "Custom…" (sticky),
|
||||
/// or the stored size simply isn't one of the presets (e.g. a value synced from a Mac) — so a
|
||||
/// non-preset mode stays editable across relaunches without a persisted flag.
|
||||
private var isCustomResolution: Bool {
|
||||
customMode || !presetResolutionTags.contains("\(width)x\(height)")
|
||||
}
|
||||
|
||||
/// The wheel works in "WxH" tags so one selection drives both width and height; the custom
|
||||
/// sentinel toggles `customMode` instead of writing a size.
|
||||
private var resolutionSelection: Binding<String> {
|
||||
Binding(
|
||||
get: { isCustomResolution ? Self.customResolutionTag : "\(width)x\(height)" },
|
||||
set: { tag in
|
||||
if tag == Self.customResolutionTag {
|
||||
customMode = true
|
||||
return
|
||||
}
|
||||
customMode = false
|
||||
let parts = tag.split(separator: "x").compactMap { Int($0) }
|
||||
guard parts.count == 2 else { return }
|
||||
width = parts[0]
|
||||
height = parts[1]
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh rates the device can actually display (no point asking the host to render frames the
|
||||
/// screen can't show), plus any stored custom value so it stays selectable.
|
||||
private var refreshChoices: [Int] {
|
||||
let maxHz = UIScreen.main.maximumFramesPerSecond
|
||||
var rates = [60, 120, 240].filter { $0 <= maxHz }
|
||||
if rates.isEmpty { rates = [maxHz] }
|
||||
if !rates.contains(hz) { rates.append(hz) }
|
||||
return rates.sorted()
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder private var audioSection: some View {
|
||||
Section {
|
||||
Picker("Audio channels", selection: $audioChannels) {
|
||||
@@ -321,7 +576,7 @@ struct SettingsView: View {
|
||||
Text("Host audio plays through the speaker; the microphone feeds the "
|
||||
+ "host's virtual mic. System default follows macOS device changes. "
|
||||
+ "Applies from the next session.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -341,7 +596,7 @@ struct SettingsView: View {
|
||||
Text("Which compositor drives the virtual output on the host. A specific "
|
||||
+ "choice is honored only if that backend is available there — "
|
||||
+ "otherwise the host falls back to auto-detection.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -355,26 +610,47 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Take the window fullscreen when a session starts and restore it on the host "
|
||||
+ "list, so only the stream is fullscreen — not the picker.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it
|
||||
// recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a
|
||||
// lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like
|
||||
// the controller test. Empty in release builds (no presenter UI; stage-2 always).
|
||||
@ViewBuilder private var presenterSection: some View {
|
||||
#if DEBUG
|
||||
Section {
|
||||
Picker("Presenter", selection: $presenter) {
|
||||
Text("Stage 1 (default)").tag("stage1")
|
||||
Text("Stage 2 (experimental)").tag("stage2")
|
||||
Text("Stage 2 (default)").tag("stage2")
|
||||
Text("Stage 1 (debug)").tag("stage1")
|
||||
}
|
||||
} header: {
|
||||
Text("Video presenter")
|
||||
Text("Video presenter · debug")
|
||||
} footer: {
|
||||
Text("Stage 1 feeds compressed video to the system display layer (known-good). "
|
||||
+ "Stage 2 decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD "
|
||||
+ "and shortens the present tail. Applies from the next session.")
|
||||
.font(.caption)
|
||||
Text("Stage 2 (default) decodes explicitly and presents through Metal with a display "
|
||||
+ "link — it adds a capture→present (glass-to-glass) latency line in the HUD and "
|
||||
+ "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the "
|
||||
+ "system display layer; it freezes on a lost HEVC reference frame, so it's a debug "
|
||||
+ "fallback only. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var hdrSection: some View {
|
||||
Section {
|
||||
Toggle("10-bit HDR", isOn: $hdrEnabled)
|
||||
} header: {
|
||||
Text("HDR")
|
||||
} footer: {
|
||||
Text("Request a 10-bit BT.2020 PQ (HDR10) stream. It only engages when the host is "
|
||||
+ "sending HDR content AND this display supports HDR — otherwise the stream stays "
|
||||
+ "8-bit SDR. Applies from the next session.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -392,7 +668,7 @@ struct SettingsView: View {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text(Self.statisticsFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -407,7 +683,7 @@ struct SettingsView: View {
|
||||
+ "(Steam + custom) via the host's management API; tap a title to launch it. "
|
||||
+ "The host must expose that API on the LAN with a token "
|
||||
+ "(serve --mgmt-bind 0.0.0.0 --mgmt-token …).")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -441,7 +717,7 @@ struct SettingsView: View {
|
||||
Text("Controllers")
|
||||
} footer: {
|
||||
Text(Self.controllersFooter)
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -593,13 +869,13 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if gamepads.active?.id == controller.id {
|
||||
Text("In use")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(.green.opacity(0.2)))
|
||||
@@ -621,6 +897,10 @@ struct SettingsView: View {
|
||||
width = Int(max(bounds.width, bounds.height))
|
||||
height = Int(min(bounds.width, bounds.height))
|
||||
hz = UIScreen.main.maximumFramesPerSecond
|
||||
#if os(iOS)
|
||||
// The native mode is the "This device" wheel row, so leave Custom mode if it was on.
|
||||
customMode = false
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -631,3 +911,52 @@ extension Double {
|
||||
Swift.min(Swift.max(self, lo), hi)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The settings groups, mirroring the macOS preference tabs. On iPad each is a sidebar row that
|
||||
/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not
|
||||
/// private) so the screenshot harness can open SettingsView on a specific category.
|
||||
enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
case general, display, audio, controllers, advanced, about
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: return "General"
|
||||
case .display: return "Display"
|
||||
case .audio: return "Audio"
|
||||
case .controllers: return "Controllers"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .general: return "gearshape"
|
||||
case .display: return "display"
|
||||
case .audio: return "speaker.wave.2"
|
||||
case .controllers: return "gamecontroller"
|
||||
case .advanced: return "slider.horizontal.3"
|
||||
case .about: return "info.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Present the settings sheet large on iPad so the NavigationSplitView has room for its
|
||||
/// sidebar + detail — a default form sheet is too narrow and the split view would collapse to
|
||||
/// the iPhone push list. No-op on iPhone (the standard sheet is already right) and on iOS 17
|
||||
/// (no `presentationSizing` — it falls back to the default sheet, which still degrades cleanly
|
||||
/// to the push list).
|
||||
@ViewBuilder
|
||||
func settingsSheetSizing() -> some View {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad, #available(iOS 18, *) {
|
||||
presentationSizing(.page)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -52,7 +52,7 @@ struct SpeedTestSheet: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
|
||||
.font(.headline)
|
||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
||||
.foregroundStyle(.tint)
|
||||
|
||||
switch phase {
|
||||
@@ -73,7 +73,7 @@ struct SpeedTestSheet: View {
|
||||
resultView(result)
|
||||
case .failed(let message):
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
@@ -149,13 +149,13 @@ struct SpeedTestSheet: View {
|
||||
if let rec = Self.recommendedKbps(result) {
|
||||
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
|
||||
+ "(~70% of measured, headroom for encoder bursts).")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
Text("Too little data made it through to recommend a bitrate — "
|
||||
+ "check the network and retry.")
|
||||
.font(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
@@ -69,19 +69,19 @@ struct StreamHUDView: View {
|
||||
Text(model.mouseCaptured
|
||||
? "⌘⎋ releases the mouse"
|
||||
: "Click the stream to capture input")
|
||||
.font(.caption2)
|
||||
.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(.caption2)
|
||||
.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(.caption2)
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
@@ -89,13 +89,13 @@ struct StreamHUDView: View {
|
||||
// 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)
|
||||
.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(.caption)
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// or drops this and runs the PIN pairing ceremony instead.
|
||||
|
||||
import Foundation
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct TrustCardView: View {
|
||||
@@ -18,11 +19,11 @@ struct TrustCardView: View {
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
Text("Verify \(hostName)")
|
||||
.font(.title3.weight(.semibold))
|
||||
.font(.geist(20, .semibold, relativeTo: .title3))
|
||||
Text("First connection. Compare this fingerprint with the one "
|
||||
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
||||
+ "fingerprint\u{201D}):")
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(Self.format(fingerprint: fingerprint))
|
||||
@@ -58,7 +59,7 @@ struct TrustCardView: View {
|
||||
#else
|
||||
.buttonStyle(.borderless)
|
||||
#endif
|
||||
.font(.callout)
|
||||
.font(.geist(16, relativeTo: .callout))
|
||||
}
|
||||
.padding(28)
|
||||
.frame(maxWidth: 440)
|
||||
|
||||
Reference in New Issue
Block a user