diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift index 7deaef3..3447872 100644 --- a/clients/apple/Package.swift +++ b/clients/apple/Package.swift @@ -24,6 +24,9 @@ let package = Package( .copy("Resources/THIRD-PARTY-NOTICES.txt"), .copy("Resources/LICENSE-MIT.txt"), .copy("Resources/LICENSE-APACHE.txt"), + // Geist (SIL OFL 1.1) — the brand typeface, shared with punktfunk-website. + // Registered with Core Text at first use; see BrandFont.swift. + .copy("Resources/Fonts"), ], linkerSettings: [ // Rust staticlib system deps. diff --git a/clients/apple/Sources/PunktfunkClient/AcknowledgementsView.swift b/clients/apple/Sources/PunktfunkClient/AcknowledgementsView.swift index cc19374..02b89af 100644 --- a/clients/apple/Sources/PunktfunkClient/AcknowledgementsView.swift +++ b/clients/apple/Sources/PunktfunkClient/AcknowledgementsView.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/BrandTheme.swift b/clients/apple/Sources/PunktfunkClient/BrandTheme.swift new file mode 100644 index 0000000..7f7722d --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/BrandTheme.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 0a18ba6..c8d1319 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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) } } diff --git a/clients/apple/Sources/PunktfunkClient/ControllerTestView.swift b/clients/apple/Sources/PunktfunkClient/ControllerTestView.swift index 0da3277..10bcaa6 100644 --- a/clients/apple/Sources/PunktfunkClient/ControllerTestView.swift +++ b/clients/apple/Sources/PunktfunkClient/ControllerTestView.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/HomeView.swift b/clients/apple/Sources/PunktfunkClient/HomeView.swift index aee5374..019949a 100644 --- a/clients/apple/Sources/PunktfunkClient/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/HomeView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/HostCards.swift b/clients/apple/Sources/PunktfunkClient/HostCards.swift index 9ad1641..b2ebccb 100644 --- a/clients/apple/Sources/PunktfunkClient/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/HostCards.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/LibraryView.swift index edf2b34..4de9833 100644 --- a/clients/apple/Sources/PunktfunkClient/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/LibraryView.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/PairSheet.swift index e1f5755..9f46a18 100644 --- a/clients/apple/Sources/PunktfunkClient/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/PairSheet.swift @@ -48,7 +48,7 @@ struct PairSheet: View { + "(http://: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://: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) } } diff --git a/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift b/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift index ce5ed76..a723b46 100644 --- a/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift +++ b/clients/apple/Sources/PunktfunkClient/PunktfunkClientApp.swift @@ -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= → 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= → 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 } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index dec3cd8..f06e378 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -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()) diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 7123cb4..bf88fa1 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -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 { + 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() + 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 { + 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 { + 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 diff --git a/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift b/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift index 3fa2770..467b235 100644 --- a/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift @@ -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) } diff --git a/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift index f59cd18..f561386 100644 --- a/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/TrustCardView.swift b/clients/apple/Sources/PunktfunkClient/TrustCardView.swift index acb0dab..82ebf12 100644 --- a/clients/apple/Sources/PunktfunkClient/TrustCardView.swift +++ b/clients/apple/Sources/PunktfunkClient/TrustCardView.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkKit/BrandFont.swift b/clients/apple/Sources/PunktfunkKit/BrandFont.swift new file mode 100644 index 0000000..27aeaa7 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/BrandFont.swift @@ -0,0 +1,101 @@ +// Geist — the punktfunk brand typeface (the same family the website ships). Bundled as static +// OTF weights in this kit's resources and registered with Core Text at first use, so it works +// identically in the Xcode app and the `swift run` dev shell (Bundle.module resolves to the +// package resource bundle in both). Geist Sans carries titles/UI; Geist Mono carries the technical +// readouts — host addresses, status labels, the stream-stats HUD — for the industrial look. +// +// Licensed under the SIL Open Font License 1.1 (Resources/Fonts/Geist-OFL.txt). + +import CoreText +import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +public enum BrandFont { + public enum Weight { + case regular, medium, semibold, bold + } + + /// PostScript names of the bundled faces (verified from each OTF's name table). Geist Sans only + /// — Geist Mono is intentionally not shipped; the app's typeface is Geist Sans throughout. + private static let sansFaces = ["Geist-Regular", "Geist-Medium", "Geist-SemiBold", "Geist-Bold"] + + /// Registered exactly once per process — a static `let` initializer is run lazily and is + /// guaranteed thread-safe + run-at-most-once by the runtime. + private static let registered: Void = { + for face in sansFaces { + guard let url = Bundle.module.url( + forResource: face, withExtension: "otf", subdirectory: "Fonts") else { + #if DEBUG + print("BrandFont: bundled face \(face).otf not found — text will fall back to system") + #endif + continue + } + var error: Unmanaged? + if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) { + #if DEBUG + let message = error?.takeRetainedValue().localizedDescription ?? "unknown error" + print("BrandFont: failed to register \(face): \(message)") + #endif + } + } + }() + + /// Force registration before the first `Font.custom` lookup. Cheap to call repeatedly. + public static func registerIfNeeded() { _ = registered } + + fileprivate static func sansFace(_ weight: Weight) -> String { + switch weight { + case .regular: return "Geist-Regular" + case .medium: return "Geist-Medium" + case .semibold: return "Geist-SemiBold" + case .bold: return "Geist-Bold" + } + } +} + +public extension Color { + /// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly, + /// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and + /// timing-sensitive (it can fall back to system blue), and the brand mark must never drift. + /// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces). + static let brand: Color = { + #if canImport(UIKit) + return Color(UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1) + : UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1) + }) + #elseif canImport(AppKit) + return Color(NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + ? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1) + : NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1) + }) + #else + // Non-Apple fallback: the light brand value, so all branches agree on a canonical color. + return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255) + #endif + }() +} + +public extension Font { + /// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`. + static func geist( + _ size: CGFloat, _ weight: BrandFont.Weight = .regular, + relativeTo textStyle: TextStyle = .body + ) -> Font { + BrandFont.registerIfNeeded() + return .custom(BrandFont.sansFace(weight), size: size, relativeTo: textStyle) + } + + /// Geist Sans at a FIXED point size that does not scale with Dynamic Type — for glyphs pinned + /// inside a fixed-size container (e.g. the monogram tile), where a scaled letter would overflow. + static func geistFixed(_ size: CGFloat, _ weight: BrandFont.Weight = .regular) -> Font { + BrandFont.registerIfNeeded() + return .custom(BrandFont.sansFace(weight), fixedSize: size) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift index 1b9c281..4a4c9da 100644 --- a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift @@ -22,6 +22,9 @@ public enum DefaultsKey { public static let speakerUID = "punktfunk.speakerUID" public static let micUID = "punktfunk.micUID" public static let presenter = "punktfunk.presenter" + /// Request a 10-bit BT.2020 PQ (HDR10) stream. On by default; only takes effect when the host + /// has HDR content AND this display supports HDR — otherwise the stream stays 8-bit SDR. + public static let hdrEnabled = "punktfunk.hdrEnabled" public static let hosts = "punktfunk.hosts" /// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never". public static let cursorMode = "punktfunk.cursorMode" diff --git a/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift index 94a3cda..0397a38 100644 --- a/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift @@ -68,6 +68,14 @@ private final class RumbleRenderer: @unchecked Sendable { private var broken = false /// Last logged active/silent state — for a one-line transition log, not per-event spam. private var wasActive = false + // Backoff after an engine failure. A broken `gamecontrollerd.haptics` XPC connection (CoreHaptics + // -4811 "server connection broke") fails EVERY rebuild until the service relaunches — and that + // break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble + // update immediately rebuilds into the same dead connection, flooding the log and never + // recovering. Delay the next setup() — growing 0.5→1→2→4 s on repeated failure — and clear it + // the moment a player runs cleanly (or the controller changes). + private var retryAfter = Date.distantPast + private var consecutiveFailures = 0 /// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a /// defined frequency to move at all — an intensity-only event (no sharpness) left them @@ -91,6 +99,8 @@ private final class RumbleRenderer: @unchecked Sendable { self.closeHID() self.controller = c self.broken = false + self.consecutiveFailures = 0 + self.retryAfter = .distantPast _ = self.openHIDIfDualSense(c) onBackend?(self.backendNote(for: c)) } @@ -108,7 +118,7 @@ private final class RumbleRenderer: @unchecked Sendable { // other pad (and for a DualSense whose HID device could not be opened). if self.hidRumble(low: lowAmp, high: highAmp) { return } guard !self.broken else { return } - if active, self.low == nil, self.high == nil { + if active, self.low == nil, self.high == nil, Date() >= self.retryAfter { self.setup() } let ok: Bool @@ -124,8 +134,15 @@ private final class RumbleRenderer: @unchecked Sendable { } // Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE // the `inout` accesses above, so teardown() never mutates a motor that a `drive` call - // still holds an exclusive reference to. - if !ok { self.teardown() } + // still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every + // update; once a player is actually running the path has recovered, so clear the backoff. + if !ok { + self.teardown() + self.scheduleRetryBackoff() + } else if self.low?.player != nil || self.high?.player != nil { + self.consecutiveFailures = 0 + self.retryAfter = .distantPast + } } } @@ -157,14 +174,29 @@ private final class RumbleRenderer: @unchecked Sendable { low = makeMotor(haptics, .default) } if low == nil, high == nil { - // Haptics present but no engine could be built right now (server busy / a transient - // error). Do NOT latch broken — the next nonzero amplitude retries setup(). - log.warning("rumble: haptics present but engine setup failed — will retry on next rumble") + // Haptics present but no engine could be built right now (server busy / XPC broken). Do + // NOT latch broken — back off and the next nonzero amplitude past the cooldown retries. + log.warning("rumble: haptics present but engine setup failed — backing off, will retry") + scheduleRetryBackoff() } } + /// Push the next engine-build attempt out after a failure (capped exponential backoff), so a + /// broken `gamecontrollerd.haptics` connection gets time to relaunch instead of being re-hit on + /// every rumble update. + private func scheduleRetryBackoff() { + consecutiveFailures += 1 + let shift = min(consecutiveFailures - 1, 4) + retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4)) + } + private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? { guard let engine = haptics.createEngine(withLocality: locality) else { return nil } + // A controller's motors carry no audio, so keep this engine OUT of the app's audio session + // (the default is to join it). Streaming keeps an AVAudioSession active the whole time; + // letting a haptics-only engine join it is a needless coupling that can get its + // gamecontrollerd XPC connection interrupted (the repeated -4811 server-connection breaks). + engine.playsHapticsOnly = true // The haptic server can stop or reset the engine out from under us — app backgrounding, an // audio-session interruption (a call, Siri, another audio app), or a server crash. Left // unhandled the players go dead and every later rumble throws, latching rumble off for the diff --git a/clients/apple/Sources/PunktfunkKit/Licenses.swift b/clients/apple/Sources/PunktfunkKit/Licenses.swift index a0b514a..f117415 100644 --- a/clients/apple/Sources/PunktfunkKit/Licenses.swift +++ b/clients/apple/Sources/PunktfunkKit/Licenses.swift @@ -27,6 +27,17 @@ public enum Licenses { + apache } + /// The bundled brand typeface (Geist Sans + Geist Mono) — SIL Open Font License 1.1. The + /// license file ships alongside the OTFs in `Resources/Fonts/`, satisfying the OFL's + /// distribution requirement; this surfaces it in the Acknowledgements screen too. + public static var fontLicense: String { + guard let url = Bundle.module.url( + forResource: "Geist-OFL", withExtension: "txt", subdirectory: "Fonts"), + let text = try? String(contentsOf: url, encoding: .utf8) + else { return "" } + return text + } + /// Third-party software notices for the linked Rust crates (generated by /// `scripts/gen-third-party-notices.sh`). public static var thirdPartyNotices: String { diff --git a/clients/apple/Sources/PunktfunkKit/MetalVideoPresenter.swift b/clients/apple/Sources/PunktfunkKit/MetalVideoPresenter.swift index bf9be22..529c391 100644 --- a/clients/apple/Sources/PunktfunkKit/MetalVideoPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/MetalVideoPresenter.swift @@ -11,6 +11,9 @@ import CoreGraphics import CoreVideo import Metal import QuartzCore +import os + +private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter") /// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and a /// BT.709 limited-range NV12→RGB fragment shader. uv.y is flipped (1 - p.y) so the top-left- @@ -30,11 +33,44 @@ vertex VOut pf_vtx(uint vid [[vertex_id]]) { return o; } +// Bicubic (Catmull-Rom) sampling of the single-channel luma plane. When the drawable is larger +// than the decoded frame (a window/view bigger than the host's fixed mode), a bilinear upscale +// looks soft; Catmull-Rom keeps edges crisp — matching AVSampleBufferDisplayLayer's (stage-1) +// scaler — and reduces to the exact texel at 1:1, so a native-resolution present stays pixel-exact. +// Nine bilinear taps (TheRealMJP's optimisation of the 16-tap kernel); `s` MUST be a linear +// sampler. Luma carries the perceived detail, so only it gets bicubic; chroma stays bilinear. +float catmullRomLuma(texture2d tex, sampler s, float2 uv) { + float2 texSize = float2(tex.get_width(), tex.get_height()); + float2 samplePos = uv * texSize; + float2 tc1 = floor(samplePos - 0.5) + 0.5; + float2 f = samplePos - tc1; + float2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f)); + float2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f); + float2 w2 = f * (0.5 + f * (2.0 - 1.5 * f)); + float2 w3 = f * f * (-0.5 + 0.5 * f); + float2 w12 = w1 + w2; + float2 off12 = w2 / w12; + float2 tc0 = (tc1 - 1.0) / texSize; + float2 tc3 = (tc1 + 2.0) / texSize; + float2 tc12 = (tc1 + off12) / texSize; + float r = 0.0; + r += tex.sample(s, float2(tc0.x, tc0.y)).r * (w0.x * w0.y); + r += tex.sample(s, float2(tc12.x, tc0.y)).r * (w12.x * w0.y); + r += tex.sample(s, float2(tc3.x, tc0.y)).r * (w3.x * w0.y); + r += tex.sample(s, float2(tc0.x, tc12.y)).r * (w0.x * w12.y); + r += tex.sample(s, float2(tc12.x, tc12.y)).r * (w12.x * w12.y); + r += tex.sample(s, float2(tc3.x, tc12.y)).r * (w3.x * w12.y); + r += tex.sample(s, float2(tc0.x, tc3.y)).r * (w0.x * w3.y); + r += tex.sample(s, float2(tc12.x, tc3.y)).r * (w12.x * w3.y); + r += tex.sample(s, float2(tc3.x, tc3.y)).r * (w3.x * w3.y); + return r; +} + fragment float4 pf_frag(VOut in [[stage_in]], texture2d lumaTex [[texture(0)]], texture2d chromaTex [[texture(1)]]) { constexpr sampler s(filter::linear, address::clamp_to_edge); - float y = lumaTex.sample(s, in.uv).r; + float y = catmullRomLuma(lumaTex, s, in.uv); float2 c = chromaTex.sample(s, in.uv).rg; // BT.709, 8-bit limited (video) range → full-range RGB. y = (y - 16.0/255.0) * (255.0/219.0); @@ -55,7 +91,7 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]], texture2d lumaTex [[texture(0)]], texture2d chromaTex [[texture(1)]]) { constexpr sampler s(filter::linear, address::clamp_to_edge); - float y = lumaTex.sample(s, in.uv).r; + float y = catmullRomLuma(lumaTex, s, in.uv); float2 c = chromaTex.sample(s, in.uv).rg; // BT.2020 10-bit limited (video) range → full-range PQ R'G'B'. y = (y - 64.0/1023.0) * (1023.0/876.0); @@ -81,6 +117,11 @@ public final class MetalVideoPresenter { private var textureCache: CVMetalTextureCache? /// Current layer configuration — switched lazily in `configure(hdr:)` when a frame's mode differs. private var hdrActive = false + #if DEBUG + /// Last logged "decoded→drawable" signature, so the diagnostic logs only when a size changes + /// (on first frame, a resize, or a host Reconfigure) instead of every frame. + private var lastSizeSig = "" + #endif /// nil if Metal is unavailable (no GPU / a headless CI) — the caller falls back to stage-1. public init?() { @@ -113,6 +154,12 @@ public final class MetalVideoPresenter { layer.pixelFormat = .bgra8Unorm layer.framebufferOnly = true layer.isOpaque = true + // Render the drawable at the DECODED frame's resolution (set per-frame in `render`) and let + // the system compositor scale it to the layer's bounds — the same `.resizeAspect` path + // stage-1's AVSampleBufferDisplayLayer (videoGravity) uses, so stage-2 matches its sharpness. + // A native-resolution present is then pixel-exact (1:1, no shader scaling), and any display + // scaling uses the system's high-quality scaler rather than the in-shader bicubic. + layer.contentsGravity = .resizeAspect // Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the // display-link / MAIN thread) has to block waiting for one to free. layer.maximumDrawableCount = 3 @@ -129,12 +176,6 @@ public final class MetalVideoPresenter { self.layer = layer } - /// Track the stream mode (the host can Reconfigure mid-stream). Size is in pixels. - public func setDrawableSize(_ size: CGSize) { - guard size.width > 0, size.height > 0 else { return } - if layer.drawableSize != size { layer.drawableSize = size } - } - /// Reconfigure the layer for SDR or HDR when the stream mode flips (HDR toggle). HDR uses an /// rgba16Float drawable + a BT.2020 PQ colour space + EDR, so the compositor PQ-maps to the /// display; SDR uses the plain 8-bit sRGB path. Main-thread only (called from `render`). @@ -171,13 +212,33 @@ public final class MetalVideoPresenter { let chroma = makeTexture(pixelBuffer, plane: 1, format: chromaFmt, cache: textureCache) else { return false } - // The hosting view owns drawableSize (aspect-fit to its bounds); skip until it's laid - // out. The fullscreen triangle scales the decoded texture to fill the drawable. - guard layer.drawableSize.width > 0, layer.drawableSize.height > 0, - let drawable = layer.nextDrawable(), + // Size the drawable to the decoded frame so the fullscreen triangle samples the texture 1:1 + // (pixel-exact); the layer's contentsGravity then scales it to the on-screen bounds via the + // system compositor (matching stage-1). Re-set only on a change (first frame / Reconfigure). + let decodedSize = CGSize( + width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer)) + if layer.drawableSize != decodedSize { layer.drawableSize = decodedSize } + guard let drawable = layer.nextDrawable(), let commandBuffer = queue.makeCommandBuffer() else { return false } + #if DEBUG + // Diagnose sharpness: decoded should equal the drawable (the shader is 1:1); the layer's + // bounds may differ (the system scales). Logged only when a size changes. + let decodedW = Int(decodedSize.width) + let decodedH = Int(decodedSize.height) + let sig = "\(decodedW)x\(decodedH)|\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height))" + if sig != lastSizeSig { + lastSizeSig = sig + let msg = "stage2: decoded \(decodedW)x\(decodedH) → drawable " + + "\(Int(layer.drawableSize.width))x\(Int(layer.drawableSize.height)) " + + "(texture \(drawable.texture.width)x\(drawable.texture.height), " + + "contentsScale \(layer.contentsScale), " + + "layerBounds \(Int(layer.bounds.width))x\(Int(layer.bounds.height)))" + presenterLog.info("\(msg, privacy: .public)") + } + #endif + let pass = MTLRenderPassDescriptor() pass.colorAttachments[0].texture = drawable.texture pass.colorAttachments[0].loadAction = .clear diff --git a/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-Bold.otf b/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-Bold.otf new file mode 100644 index 0000000..6ab5615 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-Bold.otf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-Medium.otf b/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-Medium.otf new file mode 100644 index 0000000..99fb7c2 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-Medium.otf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-OFL.txt b/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-OFL.txt new file mode 100644 index 0000000..04e95fc --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-Regular.otf b/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-Regular.otf new file mode 100644 index 0000000..8287833 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-Regular.otf differ diff --git a/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-SemiBold.otf b/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-SemiBold.otf new file mode 100644 index 0000000..277a521 Binary files /dev/null and b/clients/apple/Sources/PunktfunkKit/Resources/Fonts/Geist-SemiBold.otf differ diff --git a/clients/apple/Sources/PunktfunkKit/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/SessionAudio.swift index 7e10459..c723fec 100644 --- a/clients/apple/Sources/PunktfunkKit/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/SessionAudio.swift @@ -177,6 +177,16 @@ public final class SessionAudio { private var playbackEngine: AVAudioEngine? private var captureEngine: AVAudioEngine? private var drainStarted = false + #if !os(macOS) + /// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so + /// they must not run on the main thread (UI stall — AVFoundation warns about it). PROCESS-WIDE + /// (static) so every SessionAudio shares one serial queue: the AVAudioSession is a process + /// singleton, and across a reconnect the old session's deactivate must be ordered before the + /// new session's activate (a per-instance queue would let them race and leave the new session's + /// audio deactivated). stop() enqueues its deactivate promptly so it lands before the next + /// session's activate. + private static let sessionQueue = DispatchQueue(label: "io.unom.punktfunk.audio.session") + #endif public init(connection: PunktfunkConnection) { self.connection = connection @@ -189,37 +199,60 @@ public final class SessionAudio { flag.stop() } - /// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system - /// default device; on iOS the UIDs are ignored entirely (routes are - /// AVAudioSession-managed). Main thread (engine setup); returns after the engines - /// start — the mic may start slightly later if the permission prompt is pending. + /// Start playback (and, if enabled+authorized, the mic uplink). Empty UIDs = system default + /// device; on iOS the UIDs are ignored entirely (routes are AVAudioSession-managed). On macOS + /// the engines start synchronously on the caller's (main) thread. On iOS/tvOS start() is + /// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on + /// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not + /// on return. The mic may start later still if the permission prompt is pending. public func start(speakerUID: String, micUID: String, micEnabled: Bool) { - #if os(iOS) - // Route + policy live in the session, not per-engine: stereo playback, mic - // capture when enabled, Bluetooth allowed. Failure is non-fatal (defaults). + #if os(macOS) + // No AVAudioSession on macOS — start the engines directly (caller's thread, as before). + startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled) + #else + // Configure + activate the session OFF the main thread (it blocks on the audio server), + // then start the engines back on the main thread once it's active — engine routing/format + // depend on the active session. A stop() racing in between is caught by the flag guard. + Self.sessionQueue.async { [weak self] in + guard let self else { return } + self.activateAudioSession(micEnabled: micEnabled) + DispatchQueue.main.async { [weak self] in + guard let self, !self.flag.isStopped else { return } + self.startEngines(speakerUID: speakerUID, micUID: micUID, micEnabled: micEnabled) + } + } + #endif + } + + #if !os(macOS) + /// Route + policy live in the session, not per-engine: stereo playback, mic capture when + /// enabled, Bluetooth allowed. Failure is non-fatal (defaults). Runs on `sessionQueue`. + private func activateAudioSession(micEnabled: Bool) { let session = AVAudioSession.sharedInstance() do { + #if os(iOS) if micEnabled { - // .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone - // EARPIECE; only affects the built-in route (headphones/BT still win). + // .defaultToSpeaker: .playAndRecord otherwise routes to the iPhone EARPIECE; only + // affects the built-in route (headphones/BT still win). try session.setCategory( .playAndRecord, mode: .default, options: [.allowBluetoothA2DP, .defaultToSpeaker]) } else { try session.setCategory(.playback, mode: .default) } + #else // tvOS — no app-accessible mic + try session.setCategory(.playback, mode: .default) + #endif try session.setActive(true) } catch { log.warning("AVAudioSession setup failed: \(error.localizedDescription)") } - #elseif os(tvOS) - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) - try AVAudioSession.sharedInstance().setActive(true) - } catch { - log.warning("AVAudioSession setup failed: \(error.localizedDescription)") - } - #endif + } + #endif + + /// Build + start the playback engine (and the mic uplink when enabled + authorized). Main + /// thread (engine setup); on iOS/tvOS the session is already active by the time this runs. + private func startEngines(speakerUID: String, micUID: String, micEnabled: Bool) { startPlayback(speakerUID: speakerUID) #if os(tvOS) // No app-accessible microphone input on tvOS — playback only. @@ -258,19 +291,24 @@ public final class SessionAudio { capture.stop() } playback?.stop() + #if !os(macOS) + // Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like + // activation, setActive is synchronous/blocking — run it on the shared serial session queue + // (off the main thread). Enqueued HERE — engines already stopped, and BEFORE the drain wait + // below — so across a reconnect it lands ahead of the next session's activate on the shared + // queue (otherwise a deferred deactivate could deactivate the new session). Fire-and-forget. + Self.sessionQueue.async { + do { + try AVAudioSession.sharedInstance().setActive( + false, options: .notifyOthersOnDeactivation) + } catch { + log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)") + } + } + #endif if wasDraining { _ = drainDone.wait(timeout: .now() + .milliseconds(400)) } - #if !os(macOS) - // Release the session so audio we interrupted (Music, podcasts) gets its - // resume cue. - do { - try AVAudioSession.sharedInstance().setActive( - false, options: .notifyOthersOnDeactivation) - } catch { - log.warning("AVAudioSession deactivation failed: \(error.localizedDescription)") - } - #endif } // MARK: - Playback (host → speaker) diff --git a/clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift index 18ed298..2a65908 100644 --- a/clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift @@ -4,7 +4,7 @@ // capture→present. Mirrors StreamPump's lifecycle (one per start; cancel is permanent). // // Threading: the pump runs on its own thread; the decoder callback on a VT thread; `renderTick` -// + `setDrawableSize` + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). +// + `start`/`stop` on the MAIN thread (the view's CADisplayLink fires there). // Only the ring + decoder cross threads and both are internally locked. #if canImport(Metal) && canImport(QuartzCore) @@ -60,7 +60,7 @@ private final class KeyframeRecovery: @unchecked Sendable { func request() { lock.lock() let now = DispatchTime.now().uptimeNanoseconds - let due = lastNs == 0 || now &- lastNs > 250_000_000 // ≥ 250 ms since the last request + let due = lastNs == 0 || now &- lastNs > 100_000_000 // ≥ 100 ms since the last request (matches Android) if due { lastNs = now } let conn = due ? connection : nil lock.unlock() @@ -114,20 +114,24 @@ public final class Stage2Pipeline { let thread = Thread { var format: CMVideoFormatDescription? var lastFramesDropped = connection.framesDropped() + // Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale): + // the old code advanced lastFramesDropped on the same edge it called recovery.request(), + // so a request swallowed by the throttle (the lost recovery IDR being pruned within the + // window) was never re-sent and the picture stayed frozen. Keep asking until an IDR lands. + var awaitingIDR = false while token.isLive { do { - // Loss recovery (the primary recovery path). The reassembler drops unrecoverable - // AUs (framesDropped) and the decoder then conceals the reference-missing delta - // frames that follow — often rendering them WITHOUT an error callback — so the - // onDecodeError trigger rarely fires after a real network blip. Ask the host for - // a fresh IDR whenever the drop count climbs (throttled in KeyframeRecovery). - // Polled every iteration so a total-loss drought recovers the moment packets - // resume and the reassembler counts the gap. + // Loss recovery (the primary path). The reassembler drops unrecoverable AUs + // (framesDropped) and the decoder conceals the reference-missing deltas that + // follow — often WITHOUT an error callback — so key off the drop count climbing, + // then keep asking (awaitingIDR) until a fresh IDR re-anchors decode. Polled every + // iteration so a total-loss drought recovers the moment packets resume. let dropped = connection.framesDropped() if dropped > lastFramesDropped { lastFramesDropped = dropped - recovery.request() + awaitingIDR = true } + if awaitingIDR { recovery.request() } // Drain any HDR mastering-metadata update (0xCE) and hand it to the decoder, which // attaches it to subsequent HDR frames. Non-blocking; only HDR sessions emit these. if connection.isHDR, let meta = try? connection.nextHdrMeta(timeoutMs: 0) { @@ -136,15 +140,16 @@ public final class Stage2Pipeline { guard let au = try connection.nextAU(timeoutMs: 100) else { continue } onFrame?(au) if let f = AnnexB.formatDescription(fromIDR: au.data) { - format = f // refreshed on every IDR (mode changes included) + format = f // refreshed on every IDR (mode changes included) + awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete } guard let f = format, token.isLive else { continue } if !decoder.decode(au: au, format: f) { // Submit/decoder error: drop the session and re-gate on the next IDR's - // in-band parameter sets (a delta frame can't recover) — stage-1's policy - // — and ask the host for that IDR now (infinite GOP; throttled). + // in-band parameter sets (a delta frame can't recover) — stage-1's policy — + // and keep asking for that IDR (infinite GOP) until one re-anchors decode. decoder.reset() - recovery.request() + awaitingIDR = true } } catch { if token.isLive { onSessionEnd?() } @@ -166,11 +171,6 @@ public final class Stage2Pipeline { presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs) } - /// MAIN thread. Keep the drawable matched to the negotiated mode (host can Reconfigure). - public func setDrawableSize(_ size: CGSize) { - presenter.setDrawableSize(size) - } - /// Stop the pump (≤ one poll timeout) and drop the decode session. Does not close the /// connection. A restart needs a fresh Stage2Pipeline (cancel is permanent). public func stop() { diff --git a/clients/apple/Sources/PunktfunkKit/StreamPump.swift b/clients/apple/Sources/PunktfunkKit/StreamPump.swift index ca976e6..4b41c30 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamPump.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamPump.swift @@ -6,6 +6,9 @@ import AVFoundation import Foundation +import os + +private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video") /// Cancellation handle owned by exactly one pump thread — a restart hands the old pump /// its own token, so it can never be revived by a newer start(). @@ -47,44 +50,74 @@ final class StreamPump { var format: CMVideoFormatDescription? var lastKeyframeRequest = Date.distantPast var lastFramesDropped = connection.framesDropped() - // Coalesced host keyframe request: the decode stays wedged for several frames until - // the IDR lands, so requesting on every frame would flood the control stream. + // Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a + // decoder reset), retry the throttled request EVERY iteration, and clear it only when a + // fresh IDR actually re-anchors decode. The old code advanced `lastFramesDropped` on the + // same edge it fired the throttled request — so a request swallowed by the throttle (a + // second drop within the window, e.g. the lost recovery IDR itself being pruned) was + // never re-sent: the counter went flat, the climb never re-fired, and the picture stayed + // frozen for good while audio kept playing. The iPhone's lossy Wi-Fi hits this where the + // Mac's Ethernet never does. + var awaitingIDR = false + var awaitingSince = Date.distantPast // when the current recovery began (for the resume log) + var wasFailed = false + // Coalesced host keyframe request. 100 ms throttle (matches the working Android path): + // fast enough that a lost recovery IDR is re-requested promptly, bounded so a sustained + // freeze can't flood the control stream. func requestKeyframeThrottled() { let now = Date() - if now.timeIntervalSince(lastKeyframeRequest) > 0.25 { + if now.timeIntervalSince(lastKeyframeRequest) > 0.1 { connection.requestKeyframe() lastKeyframeRequest = now } } while token.isLive { do { - // Loss recovery (the primary recovery path). Under the host's infinite GOP the - // only recovery keyframe is one we request. The reassembler drops unrecoverable - // AUs (framesDropped); the decoder then *conceals* the reference-missing delta - // frames that follow — a frozen / garbage picture, WITHOUT flipping the layer to - // .failed — so the .failed check below rarely fires after a real network blip. - // Ask the host for a fresh IDR whenever the drop count climbs. Polled every - // iteration (not just per AU) so a total-loss drought still recovers the moment - // packets resume and the reassembler counts the gap. + // Loss recovery (the primary path). Under the host's infinite GOP the only + // recovery keyframe is one we request. The reassembler drops unrecoverable AUs + // (framesDropped); the decoder then *conceals* the reference-missing deltas — a + // frozen / garbage picture that never flips the layer to .failed — so key off the + // drop count climbing, then keep asking (awaitingIDR) until an IDR lands. Polled + // every iteration so a total-loss drought still recovers when packets resume. let dropped = connection.framesDropped() if dropped > lastFramesDropped { + // Log only on the false→true transition (once per recovery cycle), not per + // dropped AU, so heavy loss doesn't spam the log. + if !awaitingIDR { + awaitingSince = Date() + pumpLog.notice( + "video: unrecoverable drop (framesDropped=\(dropped, privacy: .public)) — requesting recovery IDR") + } lastFramesDropped = dropped - requestKeyframeThrottled() + awaitingIDR = true } + if awaitingIDR { requestKeyframeThrottled() } + guard let au = try connection.nextAU(timeoutMs: 100) else { continue } onFrame?(au) - if let f = AnnexB.formatDescription(fromIDR: au.data) { - format = f // refreshed on every IDR (mode changes included) + let idrFormat = AnnexB.formatDescription(fromIDR: au.data) + if let f = idrFormat { + format = f // refreshed on every IDR (mode changes included) + if awaitingIDR { + let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000) + pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms") + } + awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete } - if layer.status == .failed { + let failed = layer.status == .failed + if failed { // Decode wedged hard (the cold-first-connect case — a lost/corrupt opening - // IDR): flush and re-gate on the next in-band parameter sets (resuming with - // a delta frame can't recover), AND ask the host for a fresh IDR. Throttled: - // the layer stays .failed across several polls until the IDR lands. + // IDR): flush and, unless THIS AU is the recovering IDR (re-anchored above), + // re-gate on the next in-band parameter sets and keep asking — enqueuing a + // delta into a failed layer can't recover it. + if !wasFailed { pumpLog.warning("video: display layer .failed — flushing + re-anchoring") } layer.flush() - format = AnnexB.formatDescription(fromIDR: au.data) - requestKeyframeThrottled() + if idrFormat == nil { + format = nil + awaitingIDR = true + } } + wasFailed = failed guard let f = format, let sample = AnnexB.sampleBuffer(au: au, format: f), token.isLive // don't enqueue a stale frame after a restart diff --git a/clients/apple/Sources/PunktfunkKit/StreamView.swift b/clients/apple/Sources/PunktfunkKit/StreamView.swift index 653afe9..8449984 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamView.swift @@ -245,6 +245,15 @@ public final class StreamLayerView: NSView { layoutMetalLayer() // keep the stage-2 sublayer aspect-fit to the view } + public override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + // `layout()` isn't guaranteed on a manual-frame (no-Auto-Layout) live resize, so the + // stage-2 metal sublayer's drawableSize could stay at the old size while the view grows — + // the compositor then upscales a too-small drawable and the video turns blocky. Resize the + // drawable here too so it always tracks the window's pixel size (no stale upscale). + layoutMetalLayer() + } + // MARK: - Capture state machine /// Clicking into the video engages capture; that click is local (engagement), so @@ -549,10 +558,17 @@ public final class StreamLayerView: NSView { cursorVisible = false _ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent) - // Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2 - // (`punktfunk.presenter == "stage2"`) takes explicit VTDecompressionSession decode + a - // CAMetalLayer/display-link present; it falls back here if Metal can't be set up. - if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2", + // Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a + // CAMetalLayer/display-link present): it can detect + recover a wedged decoder where + // stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is + // reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1 + // pump below stays the automatic fallback if Metal is missing). + #if DEBUG + let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1" + #else + let forceStage1 = false + #endif + if !forceStage1, let meter = presentMeter, let pipeline = Stage2Pipeline(presentMeter: meter) { startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) @@ -593,9 +609,11 @@ public final class StreamLayerView: NSView { targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp)) } - /// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode, - /// so this is usually the full bounds; it letterboxes a resized window). drawableSize is the - /// layer's pixel size — the fullscreen-triangle shader scales the decoded texture to fill it. + /// Position the metal sublayer aspect-fit in the view (the host streams at the client's native + /// mode, so this is usually the full bounds; it letterboxes a resized window). Only the layer + /// FRAME is set here — the presenter sizes the drawable to the decoded frame and the layer's + /// contentsGravity (.resizeAspect) scales it to this frame via the system compositor, so a + /// resized window rescales through the system's filter (matching stage-1) instead of the shader. private func layoutMetalLayer() { guard let metalLayer, let connection else { return } let mode = connection.currentMode() @@ -604,14 +622,12 @@ public final class StreamLayerView: NSView { aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), insideRect: bounds) : bounds - let scale = window?.backingScaleFactor ?? 1 // No implicit resize animation; refresh contentsScale on a retina↔non-retina move. CATransaction.begin() CATransaction.setDisableActions(true) - metalLayer.contentsScale = scale + metalLayer.contentsScale = window?.backingScaleFactor ?? 1 metalLayer.frame = fit CATransaction.commit() - stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale)) } public override func viewDidChangeBackingProperties() { diff --git a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift index 5bea2c8..58a402c 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift @@ -136,6 +136,13 @@ public final class StreamViewController: UIViewController { public override func loadView() { view = StreamLayerUIView() + // Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g. + // moving to an external display at a different scale) — the iOS analogue of macOS's + // viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it + // doesn't capture self (no retain cycle with the registration). + registerForTraitChanges([UITraitDisplayScale.self]) { (vc: StreamViewController, _) in + vc.layoutMetalLayer() + } #if os(iOS) // Hide the iPadOS cursor while it hovers the video: the host renders its own // cursor from our deltas, so the local one only diverges from it. This hides the @@ -219,10 +226,17 @@ public final class StreamViewController: UIViewController { inputCapture = capture #endif - // Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2 - // (`punktfunk.presenter == "stage2"`) takes VTDecompressionSession decode + a - // CAMetalLayer/display-link present; falls back here if Metal can't be set up. - if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2", + // Presenter choice — stage-2 is the DEFAULT (VTDecompressionSession decode + a + // CAMetalLayer/display-link present): it can detect + recover a wedged decoder, where + // stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no + // way to recover. Stage-1 is reachable only via the DEBUG presenter toggle; release always + // takes stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing). + #if DEBUG + let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1" + #else + let forceStage1 = false + #endif + if !forceStage1, let meter = presentMeter, let pipeline = Stage2Pipeline(presentMeter: meter) { startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) @@ -300,8 +314,8 @@ public final class StreamViewController: UIViewController { onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)? ) { let metal = pipeline.layer - metal.contentsScale = streamView.contentScaleFactor // Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base. + // (contentsScale + frame + drawableSize are all set by layoutMetalLayer() just below.) streamView.layer.addSublayer(metal) metalLayer = metal stage2 = pipeline @@ -325,9 +339,20 @@ public final class StreamViewController: UIViewController { layoutMetalLayer() } - /// Aspect-fit the metal sublayer in the view (the host streams at the client's native mode, - /// so this is usually the full bounds). drawableSize is the layer's pixel size; the shader's - /// fullscreen triangle scales the decoded texture to fill it. + /// The display scale to render the metal drawable at. `traitCollection.displayScale` is the + /// canonical render scale and is reliable once the controller is in the hierarchy; + /// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which + /// would size the drawable at point resolution → a pixelated, upscaled mess. Falls back to the + /// main screen scale if the trait is still unspecified. + private var renderScale: CGFloat { + let s = traitCollection.displayScale + return s > 0 ? s : UIScreen.main.scale + } + + /// Position the metal sublayer aspect-fit in the view (the host streams at the client's native + /// mode, so this is usually the full bounds). Only the layer FRAME is set here — the presenter + /// sizes the drawable to the decoded frame and the layer's contentsGravity (.resizeAspect) + /// scales it to this frame via the system compositor (matching stage-1's videoGravity). private func layoutMetalLayer() { guard let metalLayer, let connection else { return } let mode = connection.currentMode() @@ -337,13 +362,11 @@ public final class StreamViewController: UIViewController { aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), insideRect: bounds) : bounds - let scale = streamView.contentScaleFactor CATransaction.begin() CATransaction.setDisableActions(true) // don't animate the resize - metalLayer.contentsScale = scale + metalLayer.contentsScale = renderScale metalLayer.frame = fit CATransaction.commit() - stage2?.setDrawableSize(CGSize(width: fit.width * scale, height: fit.height * scale)) } private func teardownStage2() { diff --git a/clients/apple/Tests/PunktfunkKitTests/MetalPresenterTests.swift b/clients/apple/Tests/PunktfunkKitTests/MetalPresenterTests.swift new file mode 100644 index 0000000..3d4c71b --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/MetalPresenterTests.swift @@ -0,0 +1,21 @@ +import XCTest + +#if canImport(Metal) +import Metal +@testable import PunktfunkKit + +final class MetalPresenterTests: XCTestCase { + /// `MetalVideoPresenter.init?()` compiles the runtime Metal shaders (the BT.709/BT.2020 YUV→RGB + /// fragment shaders plus the Catmull-Rom luma sampler). A `nil` result on a GPU-equipped host + /// means a shader failed to compile — this catches a malformed shader before it silently + /// degrades stage-2 to a stage-1 fallback on device. + func testPresenterInitCompilesShaders() throws { + guard MTLCreateSystemDefaultDevice() != nil else { + throw XCTSkip("no Metal device available in this environment") + } + XCTAssertNotNil( + MetalVideoPresenter(), + "stage-2 Metal shaders failed to compile (presenter init returned nil)") + } +} +#endif