diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 4b79475..e3cf808 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -1,32 +1,30 @@ -// Hosts grid ⇄ trust prompt ⇄ live stream. +// Hosts grid ⇄ trust prompt ⇄ live stream. ContentView is the coordinator: it owns the session +// model, host store, and LAN discovery; switches between the home grid (HomeView) and the live +// session; and holds the connect logic (it reads the @AppStorage stream mode). The grid + cards +// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in +// their own files. // -// Home is a grid of saved hosts (click to connect); "+" in the toolbar adds one; the -// stream mode lives in Settings (⌘,). Two ways to establish trust on first contact: -// the TOFU prompt (host fingerprint over the live-but-blurred stream, user compares it -// with the host's log) or the PIN pairing ceremony (right-click a card → "Pair with -// PIN…", or from the trust prompt itself) — pairing verifies both sides at once and is -// the only way into hosts running --require-pairing. Once pinned, reconnects are silent -// and a changed host identity refuses to connect. +// Two ways to establish trust on first contact: the TOFU prompt (host fingerprint over the +// live-but-blurred stream, compared with the host's log) or the PIN pairing ceremony — pairing +// verifies both sides at once and is the only way into hosts running --require-pairing. Once +// pinned, reconnects are silent and a changed host identity refuses to connect. #if os(macOS) import AppKit #endif import PunktfunkKit import SwiftUI -#if os(tvOS) -import SwiftUINavigationTransitions -#endif struct ContentView: View { @StateObject private var model = SessionModel() @StateObject private var store = HostStore() @StateObject private var discovery = HostDiscovery() - @AppStorage("punktfunk.width") private var width = 1920 - @AppStorage("punktfunk.height") private var height = 1080 - @AppStorage("punktfunk.hz") private var hz = 60 - @AppStorage("punktfunk.compositor") private var compositor = 0 - @AppStorage("punktfunk.gamepadType") private var gamepadType = 0 - @AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0 + @AppStorage(DefaultsKey.streamWidth) private var width = 1920 + @AppStorage(DefaultsKey.streamHeight) private var height = 1080 + @AppStorage(DefaultsKey.streamHz) private var hz = 60 + @AppStorage(DefaultsKey.compositor) private var compositor = 0 + @AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0 + @AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0 @State private var showAddHost = false @State private var pairingTarget: StoredHost? @State private var speedTestTarget: StoredHost? @@ -64,15 +62,7 @@ struct ContentView: View { // is sequential, a pairing connection would queue behind the live session). #if !os(tvOS) .sheet(item: $pairingTarget) { host in - PairSheet(host: host) { fingerprint in - // Backstop against a stale ceremony surfacing after dismissal (PairSheet - // also self-discards those): only act while this host's sheet is up. - guard pairingTarget?.id == host.id else { return } - store.pin(host.id, fingerprint: fingerprint) - var pinned = host - pinned.pinnedSHA256 = fingerprint - connect(pinned) - } + PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) } } .sheet(item: $speedTestTarget) { host in SpeedTestSheet(host: host) @@ -80,6 +70,24 @@ struct ContentView: View { #endif } + private var home: some View { + #if os(macOS) + HomeView( + store: store, model: model, discovery: discovery, + showAddHost: $showAddHost, pairingTarget: $pairingTarget, + speedTestTarget: $speedTestTarget, + connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired) + #else + HomeView( + store: store, model: model, discovery: discovery, + showAddHost: $showAddHost, pairingTarget: $pairingTarget, + speedTestTarget: $speedTestTarget, showSettings: $showSettings, + connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired) + #endif + } + + // MARK: - Session + private var sessionView: some View { let pendingFingerprint: Data? = { if case .awaitingTrust(let fp) = model.phase { return fp } @@ -94,7 +102,20 @@ struct ContentView: View { } } if let fp = pendingFingerprint { - trustCard(fp) + TrustCardView( + fingerprint: fp, + hostName: model.activeHost?.displayName ?? "host", + onCancel: { model.rejectTrust() }, + onTrust: { + if let fp = model.confirmTrust(), let host = model.activeHost { + store.pin(host.id, fingerprint: fp) + } + }, + onPairInstead: { + let host = model.activeHost + model.rejectTrust() + pairingTarget = host + }) } } #if os(macOS) @@ -118,483 +139,6 @@ struct ContentView: View { #endif } - // MARK: - Home (hosts grid) - - private var home: some View { - NavigationStack { - Group { - if store.hosts.isEmpty && discoveredUnsaved.isEmpty { - emptyState - } else { - ScrollView { - if !store.hosts.isEmpty { - LazyVGrid(columns: gridColumns, spacing: gridSpacing) { - ForEach(store.hosts) { host in - hostCard(host) - } - } - .padding() - } - if !discoveredUnsaved.isEmpty { - discoveredSection - } - #if os(tvOS) - // Actions live below the hosts, not between them. - HStack(spacing: 32) { - Button { - showAddHost = true - } label: { - Label("Add Host", systemImage: "plus") - } - Button { - showSettings = true - } label: { - Label("Settings", systemImage: "gearshape") - } - } - .padding(.top, 24) - #endif - } - } - } - .navigationTitle("Punktfunkempfänger") - // Browse the LAN for advertised hosts only while the grid is up — not during a - // session. The home appears/disappears as the stream swaps in and out. - .onAppear { discovery.start() } - .onDisappear { discovery.stop() } - #if os(tvOS) - // Pushed routes — the Settings-app navigation feel (push animation, Menu - // pops) instead of modal overlays. - .navigationDestination(isPresented: $showAddHost) { - AddHostSheet { store.add($0) } - } - .navigationDestination(isPresented: $showSettings) { - SettingsView() - } - .navigationDestination(item: $pairingTarget) { host in - PairSheet(host: host) { fingerprint in - guard pairingTarget?.id == host.id else { return } - store.pin(host.id, fingerprint: fingerprint) - var pinned = host - pinned.pinnedSHA256 = fingerprint - connect(pinned) - } - } - .navigationDestination(item: $speedTestTarget) { host in - SpeedTestSheet(host: host) - } - #endif - #if !os(tvOS) - .toolbar { - #if os(iOS) - // Adjacent trailing items share one glass pill (the system default). - ToolbarItem(placement: .topBarTrailing) { settingsButton } - ToolbarItem(placement: .topBarTrailing) { addHostButton } - #else - ToolbarItem(placement: .primaryAction) { - addHostButton - .help("Add a host") - } - ToolbarItem { - SettingsLink { - Label("Settings", systemImage: "gearshape") - } - .help("Stream mode and settings") - } - #endif - } - #endif - } - #if os(macOS) - .frame(minWidth: 480, minHeight: 360) - #endif - #if os(tvOS) - // The Settings-app slide for every push in this stack (top-level routes AND - // the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade. - // Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast - // with just a hint of life, no visible overshoot ping-pong. - .customNavigationTransition( - .slide.animation(.interpolatingSpring(stiffness: 300, damping: 30))) - #endif - #if !os(tvOS) - .sheet(isPresented: $showAddHost) { - AddHostSheet { store.add($0) } - } - #if os(iOS) - .sheet(isPresented: $showSettings) { - NavigationStack { - SettingsView() - .navigationTitle("Settings") - .toolbar { - Button("Done") { showSettings = false } - } - } - } - #endif - #endif - .alert( - "Connection failed", - isPresented: Binding( - get: { model.errorMessage != nil }, - set: { if !$0 { model.errorMessage = nil } } - ) - ) { - Button("OK", role: .cancel) {} - } message: { - Text(model.errorMessage ?? "") - } - } - - /// macOS caps card width (a huge window shouldn't yield huge cards); on iOS the - /// columns FILL 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] { - #if os(macOS) - [GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)] - #elseif os(tvOS) - [GridItem(.adaptive(minimum: 320), spacing: 48)] - #else - [GridItem(.adaptive(minimum: 280), spacing: 16)] - #endif - } - - private var gridSpacing: CGFloat { - #if os(tvOS) - 48 // the focused card scales up — give it room instead of overlapping siblings - #else - 16 - #endif - } - - private var addHostButton: some View { - Button { - showAddHost = true - } label: { - Label("Add Host", systemImage: "plus") - } - } - - #if !os(macOS) - private var settingsButton: some View { - Button { - showSettings = true - } label: { - Label("Settings", systemImage: "gearshape") - } - } - #endif - - private var emptyState: some View { - ContentUnavailableView { - Label("No Hosts", systemImage: "rectangle.connected.to.line.below") - } description: { - Text("Add your punktfunk host with the + button.") - } actions: { - Button("Add Host") { showAddHost = true } - .buttonStyle(.borderedProminent) - #if os(iOS) - .controlSize(.large) - #endif - #if os(tvOS) - Button("Settings") { showSettings = true } - #endif - } - } - - private func hostCard(_ host: StoredHost) -> some View { - let isConnecting = model.phase == .connecting && model.activeHost?.id == host.id - #if os(iOS) - let iconSize: CGFloat = 56 - let iconBox: CGFloat = 76 - let cardPadding: CGFloat = 28 - let nameFont = Font.title3.weight(.semibold) - #else - let iconSize: CGFloat = 42 - let iconBox: CGFloat = 56 - let cardPadding: CGFloat = 18 - let nameFont = Font.headline - #endif - return Button { - connect(host) - } label: { - VStack(spacing: 10) { - ZStack { - Image(systemName: "play.display") - .font(.system(size: iconSize, weight: .light)) - .foregroundStyle(.tint) - .opacity(isConnecting ? 0.3 : 1) - if isConnecting { - ProgressView() - } - } - .frame(height: iconBox) - VStack(spacing: 2) { - Text(host.displayName) - .font(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) - } - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 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. - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) - .overlay { - if host.id == mostRecentHostID { - RoundedRectangle(cornerRadius: 14) - .strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5) - } - } - #endif - } - #if os(tvOS) - .buttonStyle(.card) - #else - .buttonStyle(.plain) - #endif - .disabled(model.isBusy) - .contextMenu { - Button("Pair with PIN…") { - guard !model.isBusy else { return } - pairingTarget = host - } - Button("Test Network Speed…") { - guard !model.isBusy else { return } - speedTestTarget = host - } - if host.pinnedSHA256 != nil { - Button("Forget Identity") { store.forgetIdentity(host) } - } - Button("Remove", role: .destructive) { store.remove(host) } - } - } - - /// First run on iOS: default the stream mode to this device's native screen so the - /// video fills the display instead of letterboxing 1920×1080 onto a 4:3 iPad. (The - /// compiled-in AppStorage defaults only apply until any value is saved; macOS keeps - /// 1080p — a desktop window is not the screen.) - private func seedDefaultModeIfNeeded() { - #if !os(macOS) - let defaults = UserDefaults.standard - guard defaults.object(forKey: "punktfunk.width") == nil else { return } - let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels - defaults.set(Int(max(bounds.width, bounds.height)), forKey: "punktfunk.width") - defaults.set(Int(min(bounds.width, bounds.height)), forKey: "punktfunk.height") - defaults.set(UIScreen.main.maximumFramesPerSecond, forKey: "punktfunk.hz") - #endif - } - - /// The host of the most recent session — its card carries the accent ring. - private var mostRecentHostID: UUID? { - store.hosts - .compactMap { host in host.lastConnected.map { (host.id, $0) } } - .max { $0.1 < $1.1 }?.0 - } - - private func connect(_ host: StoredHost) { - // The gamepad-type setting resolves NOW (Automatic → match the active physical - // controller): the host's virtual pad backend is fixed per session. - model.connect( - to: host, - width: UInt32(clamping: width), height: UInt32(clamping: height), - hz: UInt32(clamping: hz), - compositor: PunktfunkConnection.Compositor( - rawValue: UInt32(clamping: compositor)) ?? .auto, - gamepad: GamepadManager.shared.resolveType( - setting: PunktfunkConnection.GamepadType( - rawValue: UInt32(clamping: gamepadType)) ?? .auto), - bitrateKbps: UInt32(clamping: bitrateKbps)) - } - - // MARK: - LAN discovery (mDNS) - - /// Discovered hosts not already saved (matched by address+port) — the saved grid shows - /// the rest, so this section only surfaces genuinely-new hosts on the network. - private var discoveredUnsaved: [DiscoveredHost] { - discovery.hosts.filter { d in - !store.hosts.contains { $0.address == d.host && $0.port == d.port } - } - } - - private var discoveredSection: some View { - VStack(alignment: .leading, spacing: 10) { - Label("On this network", systemImage: "antenna.radiowaves.left.and.right") - .font(.headline) - .foregroundStyle(.secondary) - .padding(.horizontal) - LazyVGrid(columns: gridColumns, spacing: gridSpacing) { - ForEach(discoveredUnsaved) { discoveredCard($0) } - } - } - .padding([.horizontal, .bottom]) - .padding(.top, store.hosts.isEmpty ? 0 : 8) - } - - private func discoveredCard(_ d: DiscoveredHost) -> some View { - #if os(iOS) - let iconSize: CGFloat = 56 - let iconBox: CGFloat = 76 - let cardPadding: CGFloat = 28 - let nameFont = Font.title3.weight(.semibold) - #else - let iconSize: CGFloat = 42 - let iconBox: CGFloat = 56 - let cardPadding: CGFloat = 18 - let nameFont = Font.headline - #endif - return Button { - connectDiscovered(d) - } label: { - VStack(spacing: 10) { - Image(systemName: "play.display") - .font(.system(size: iconSize, weight: .light)) - .foregroundStyle(.tint) - .frame(height: iconBox) - VStack(spacing: 2) { - Text(d.name) - .font(nameFont) - .lineLimit(1) - HStack(spacing: 4) { - Image(systemName: d.requiresPairing ? "lock.fill" : "wifi") - .font(.system(size: 9)) - .foregroundStyle(.secondary) - Text("\(d.host):\(String(d.port))") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Text(d.requiresPairing ? "Pairing required" : "Discovered") - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, cardPadding) - .padding(.horizontal, 12) - #if !os(tvOS) - // A dashed ring distinguishes a not-yet-saved discovered host from saved cards. - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) - .overlay { - RoundedRectangle(cornerRadius: 14) - .strokeBorder( - Color.secondary.opacity(0.25), - style: StrokeStyle(lineWidth: 1, dash: [4, 3])) - } - #endif - } - #if os(tvOS) - .buttonStyle(.card) - #else - .buttonStyle(.plain) - #endif - .disabled(model.isBusy) - } - - /// Tap a discovered host: save it (so the session has a stored identity and the trust pin - /// persists), then connect — TOFU shows the fingerprint, which should match the advertised - /// `fp`. A `pair=required` host goes straight to the pairing ceremony instead. - private func connectDiscovered(_ d: DiscoveredHost) { - guard !model.isBusy else { return } - let host = StoredHost(name: d.name, address: d.host, port: d.port) - store.add(host) - if d.requiresPairing { - pairingTarget = host - } else { - connect(host) - } - } - - // MARK: - Trust on first use - - private func trustCard(_ fingerprint: Data) -> some View { - VStack(spacing: 14) { - Image(systemName: "lock.shield") - .font(.system(size: 36, weight: .light)) - .foregroundStyle(.tint) - Text("Verify \(model.activeHost?.displayName ?? "host")") - .font(.title3.weight(.semibold)) - Text("First connection. Compare this fingerprint with the one " - + "punktfunk-host logged at startup (\u{201C}clients pin this " - + "fingerprint\u{201D}):") - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - Text(Self.format(fingerprint: fingerprint)) - .font(.system(.callout, design: .monospaced)) - #if !os(tvOS) - .textSelection(.enabled) - #endif - .padding(10) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) - HStack(spacing: 12) { - Button("Cancel", role: .cancel) { model.rejectTrust() } - #if !os(tvOS) - .keyboardShortcut(.cancelAction) - #endif - Button("Trust & Connect") { - if let fp = model.confirmTrust(), let host = model.activeHost { - store.pin(host.id, fingerprint: fp) - } - } - .buttonStyle(.borderedProminent) - #if !os(tvOS) - .keyboardShortcut(.defaultAction) - #endif - } - #if os(iOS) - .controlSize(.large) - #endif - // The verified alternative to eyeballing hex: drop this session (the host - // serves one connection at a time) and run the SPAKE2 PIN ceremony instead. - Button("Pair with PIN instead…") { - let host = model.activeHost - model.rejectTrust() - pairingTarget = host - } - #if os(macOS) - .buttonStyle(.link) - #else - .buttonStyle(.borderless) - #endif - .font(.callout) - } - .padding(28) - .frame(maxWidth: 440) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18)) - } - - /// 64 hex chars → four groups per line, two lines — easy to eyeball against the log. - private static func format(fingerprint: Data) -> String { - let hex = fingerprint.map { String(format: "%02x", $0) }.joined() - let groups = stride(from: 0, to: hex.count, by: 8).map { i -> String in - let start = hex.index(hex.startIndex, offsetBy: i) - let end = hex.index(start, offsetBy: min(8, hex.count - i)) - return String(hex[start.. some View { Group { if let conn = model.connection { @@ -614,70 +158,69 @@ struct ContentView: View { presentMeter: model.presentLatency ) .overlay(alignment: .topTrailing) { - if captureEnabled { hud(conn) } + if captureEnabled { StreamHUDView(model: model, connection: conn) } } } } } - private func hud(_ conn: PunktfunkConnection) -> some View { - VStack(alignment: .trailing, spacing: 4) { - HStack(spacing: 6) { - Circle() - .fill(Color.accentColor) - .frame(width: 7, height: 7) - Text("\(conn.width)×\(conn.height)@\(conn.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s") - .font(.system(.caption, design: .monospaced)) - } - if model.latencyValid { - // Capture→client-receipt (skew-corrected); excludes the layer's decode+present — - // see LatencyMeter. "(same-host)" when the host didn't answer the skew handshake. - Text("capture→client \(model.latencyP50Ms, specifier: "%.1f")/\(model.latencyP95Ms, specifier: "%.1f") ms p50/p95\(model.latencySkewCorrected ? "" : " (same-host)")") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - if model.presentLatencyValid { - // Capture→present (glass-to-glass, modulo host render→capture) — stage-2 presenter - // only; stage-1's layer presents internally with no per-frame stamp. - Text("capture→present \(model.presentLatencyP50Ms, specifier: "%.1f")/\(model.presentLatencyP95Ms, specifier: "%.1f") ms p50/p95\(model.presentLatencySkewCorrected ? "" : " (same-host)")") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - // While captured the cursor is hidden+frozen, so the button is keyboard-only - // (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again). - #if os(macOS) - Text(model.mouseCaptured - ? "⌘⎋ releases the mouse" - : "Click the stream to capture input") - .font(.caption2) - .foregroundStyle(.secondary) - #elseif os(iOS) - // Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse. - Text(model.mouseCaptured - ? "⌘⎋ releases keyboard & mouse" - : "⌘⎋ captures keyboard & mouse") - .font(.caption2) - .foregroundStyle(.secondary) - #endif - #if os(tvOS) - // No focusable control during play: a focusable button steals the controller's - // A press (the focus engine consumes it before the host sees it). Disconnect is - // the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it. - Text("Press Menu to disconnect") - .font(.caption) - .foregroundStyle(.secondary) - #else - Button("Disconnect (⌘D)") { model.disconnect() } - .font(.caption) - .keyboardShortcut("d", modifiers: .command) - #endif - } - .padding(10) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) - .padding(10) + // MARK: - Connect + + private func connect(_ host: StoredHost) { + // The gamepad-type setting resolves NOW (Automatic → match the active physical + // controller): the host's virtual pad backend is fixed per session. + model.connect( + to: host, + width: UInt32(clamping: width), height: UInt32(clamping: height), + hz: UInt32(clamping: hz), + compositor: PunktfunkConnection.Compositor( + rawValue: UInt32(clamping: compositor)) ?? .auto, + gamepad: GamepadManager.shared.resolveType( + setting: PunktfunkConnection.GamepadType( + rawValue: UInt32(clamping: gamepadType)) ?? .auto), + bitrateKbps: UInt32(clamping: bitrateKbps)) } - // MARK: - Dev hook + /// Tap a discovered host: save it (so the session has a stored identity and the trust pin + /// persists), then connect — TOFU shows the fingerprint, which should match the advertised + /// `fp`. A `pair=required` host goes straight to the pairing ceremony instead. + private func connectDiscovered(_ d: DiscoveredHost) { + guard !model.isBusy else { return } + let host = StoredHost(name: d.name, address: d.host, port: d.port) + store.add(host) + if d.requiresPairing { + pairingTarget = host + } else { + connect(host) + } + } + + /// Pairing ceremony succeeded — pin the host and connect. The guard backstops a stale + /// ceremony surfacing after dismissal (PairSheet also self-discards those). + private func handlePaired(_ host: StoredHost, fingerprint: Data) { + guard pairingTarget?.id == host.id else { return } + store.pin(host.id, fingerprint: fingerprint) + var pinned = host + pinned.pinnedSHA256 = fingerprint + connect(pinned) + } + + // MARK: - First-run + dev hooks + + /// First run on iOS: default the stream mode to this device's native screen so the + /// video fills the display instead of letterboxing 1920×1080 onto a 4:3 iPad. (The + /// compiled-in AppStorage defaults only apply until any value is saved; macOS keeps + /// 1080p — a desktop window is not the screen.) + private func seedDefaultModeIfNeeded() { + #if !os(macOS) + let defaults = UserDefaults.standard + guard defaults.object(forKey: DefaultsKey.streamWidth) == nil else { return } + let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels + defaults.set(Int(max(bounds.width, bounds.height)), forKey: DefaultsKey.streamWidth) + defaults.set(Int(min(bounds.width, bounds.height)), forKey: DefaultsKey.streamHeight) + defaults.set(UIScreen.main.maximumFramesPerSecond, forKey: DefaultsKey.streamHz) + #endif + } /// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use, /// auto-confirmed — dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without @@ -727,9 +270,3 @@ struct ContentView: View { autoTrust: true) } } - -private extension Array { - func chunks(of size: Int) -> [[Element]] { - stride(from: 0, to: count, by: size).map { Array(self[$0.. Void + let connectDiscovered: (DiscoveredHost) -> Void + /// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness). + let onPaired: (StoredHost, Data) -> Void + + var body: some View { + NavigationStack { + Group { + if store.hosts.isEmpty && discoveredUnsaved.isEmpty { + emptyState + } else { + ScrollView { + if !store.hosts.isEmpty { + LazyVGrid(columns: gridColumns, spacing: gridSpacing) { + ForEach(store.hosts) { host in + hostCard(host) + } + } + .padding() + } + if !discoveredUnsaved.isEmpty { + discoveredSection + } + #if os(tvOS) + // Actions live below the hosts, not between them. + HStack(spacing: 32) { + Button { + showAddHost = true + } label: { + Label("Add Host", systemImage: "plus") + } + Button { + showSettings = true + } label: { + Label("Settings", systemImage: "gearshape") + } + } + .padding(.top, 24) + #endif + } + } + } + .navigationTitle("Punktfunkempfänger") + // Browse the LAN for advertised hosts only while the grid is up — not during a + // session. The home appears/disappears as the stream swaps in and out. + .onAppear { discovery.start() } + .onDisappear { discovery.stop() } + #if os(tvOS) + // Pushed routes — the Settings-app navigation feel (push animation, Menu + // pops) instead of modal overlays. + .navigationDestination(isPresented: $showAddHost) { + AddHostSheet { store.add($0) } + } + .navigationDestination(isPresented: $showSettings) { + SettingsView() + } + .navigationDestination(item: $pairingTarget) { host in + PairSheet(host: host) { fingerprint in onPaired(host, fingerprint) } + } + .navigationDestination(item: $speedTestTarget) { host in + SpeedTestSheet(host: host) + } + #endif + #if !os(tvOS) + .toolbar { + #if os(iOS) + // Adjacent trailing items share one glass pill (the system default). + ToolbarItem(placement: .topBarTrailing) { settingsButton } + ToolbarItem(placement: .topBarTrailing) { addHostButton } + #else + ToolbarItem(placement: .primaryAction) { + addHostButton + .help("Add a host") + } + ToolbarItem { + SettingsLink { + Label("Settings", systemImage: "gearshape") + } + .help("Stream mode and settings") + } + #endif + } + #endif + } + #if os(macOS) + .frame(minWidth: 480, minHeight: 360) + #endif + #if os(tvOS) + // The Settings-app slide for every push in this stack (top-level routes AND + // the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade. + // Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast + // with just a hint of life, no visible overshoot ping-pong. + .customNavigationTransition( + .slide.animation(.interpolatingSpring(stiffness: 300, damping: 30))) + #endif + #if !os(tvOS) + .sheet(isPresented: $showAddHost) { + AddHostSheet { store.add($0) } + } + #if os(iOS) + .sheet(isPresented: $showSettings) { + NavigationStack { + SettingsView() + .navigationTitle("Settings") + .toolbar { + Button("Done") { showSettings = false } + } + } + } + #endif + #endif + .alert( + "Connection failed", + isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(model.errorMessage ?? "") + } + } + + // MARK: - Cards + + private func hostCard(_ host: StoredHost) -> some View { + HostCardView( + host: host, + isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, + isMostRecent: host.id == mostRecentHostID, + isBusy: model.isBusy, + onConnect: { connect(host) }, + onPair: { if !model.isBusy { pairingTarget = host } }, + onSpeedTest: { if !model.isBusy { speedTestTarget = host } }, + onForget: { store.forgetIdentity(host) }, + onRemove: { store.remove(host) }) + } + + private var discoveredSection: some View { + VStack(alignment: .leading, spacing: 10) { + Label("On this network", systemImage: "antenna.radiowaves.left.and.right") + .font(.headline) + .foregroundStyle(.secondary) + .padding(.horizontal) + LazyVGrid(columns: gridColumns, spacing: gridSpacing) { + ForEach(discoveredUnsaved) { discovered in + DiscoveredCardView( + discovered: discovered, isBusy: model.isBusy, + onConnect: { connectDiscovered(discovered) }) + } + } + } + .padding([.horizontal, .bottom]) + .padding(.top, store.hosts.isEmpty ? 0 : 8) + } + + /// Discovered hosts not already saved (matched by address+port) — the saved grid shows the + /// rest, so this section only surfaces genuinely-new hosts on the network. + private var discoveredUnsaved: [DiscoveredHost] { + discovery.hosts.filter { d in + !store.hosts.contains { $0.address == d.host && $0.port == d.port } + } + } + + /// The host of the most recent session — its card carries the accent ring. + private var mostRecentHostID: UUID? { + store.hosts + .compactMap { host in host.lastConnected.map { (host.id, $0) } } + .max { $0.1 < $1.1 }?.0 + } + + // MARK: - Chrome + + private var emptyState: some View { + ContentUnavailableView { + Label("No Hosts", systemImage: "rectangle.connected.to.line.below") + } description: { + Text("Add your punktfunk host with the + button.") + } actions: { + Button("Add Host") { showAddHost = true } + .buttonStyle(.borderedProminent) + #if os(iOS) + .controlSize(.large) + #endif + #if os(tvOS) + Button("Settings") { showSettings = true } + #endif + } + } + + private var addHostButton: some View { + Button { + showAddHost = true + } label: { + Label("Add Host", systemImage: "plus") + } + } + + #if !os(macOS) + private var settingsButton: some View { + Button { + showSettings = true + } label: { + Label("Settings", systemImage: "gearshape") + } + } + #endif + + /// macOS caps card width (a huge window shouldn't yield huge cards); on iOS the columns FILL + /// 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] { + #if os(macOS) + [GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)] + #elseif os(tvOS) + [GridItem(.adaptive(minimum: 320), spacing: 48)] + #else + [GridItem(.adaptive(minimum: 280), spacing: 16)] + #endif + } + + private var gridSpacing: CGFloat { + #if os(tvOS) + 48 // the focused card scales up — give it room instead of overlapping siblings + #else + 16 + #endif + } +} diff --git a/clients/apple/Sources/PunktfunkClient/HostCards.swift b/clients/apple/Sources/PunktfunkClient/HostCards.swift new file mode 100644 index 0000000..42e6bcd --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/HostCards.swift @@ -0,0 +1,158 @@ +// 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. + +import PunktfunkKit +import SwiftUI + +/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS. +private struct CardMetrics { + let iconSize: CGFloat + let iconBox: CGFloat + let cardPadding: CGFloat + let nameFont: Font + + static var current: CardMetrics { + #if os(iOS) + CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold)) + #else + CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline) + #endif + } +} + +/// A saved host. The accent ring 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 + let isConnecting: Bool + let isMostRecent: Bool + let isBusy: Bool + let onConnect: () -> Void + let onPair: () -> Void + let onSpeedTest: () -> Void + let onForget: () -> Void + let onRemove: () -> Void + + 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() + } + } + .frame(height: m.iconBox) + VStack(spacing: 2) { + 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) + } + } + } + .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. + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) + .overlay { + if isMostRecent { + RoundedRectangle(cornerRadius: 14) + .strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5) + } + } + #endif + } + #if os(tvOS) + .buttonStyle(.card) + #else + .buttonStyle(.plain) + #endif + .disabled(isBusy) + .contextMenu { + Button("Pair with PIN…", action: onPair) + Button("Test Network Speed…", action: onSpeedTest) + if host.pinnedSHA256 != nil { + Button("Forget Identity", action: onForget) + } + Button("Remove", role: .destructive, action: onRemove) + } + } +} + +/// 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). +struct DiscoveredCardView: View { + let discovered: DiscoveredHost + let isBusy: Bool + let onConnect: () -> Void + + 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) { + Text(discovered.name) + .font(m.nameFont) + .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.requiresPairing ? "Pairing required" : "Discovered") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, m.cardPadding) + .padding(.horizontal, 12) + #if !os(tvOS) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) + .overlay { + RoundedRectangle(cornerRadius: 14) + .strokeBorder( + Color.secondary.opacity(0.25), + style: StrokeStyle(lineWidth: 1, dash: [4, 3])) + } + #endif + } + #if os(tvOS) + .buttonStyle(.card) + #else + .buttonStyle(.plain) + #endif + .disabled(isBusy) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/HostStore.swift b/clients/apple/Sources/PunktfunkClient/HostStore.swift index a60319c..1ee9aa8 100644 --- a/clients/apple/Sources/PunktfunkClient/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/HostStore.swift @@ -9,6 +9,7 @@ // --require-pairing only admit paired clients, so for them pairing is the only way in. import Foundation +import PunktfunkKit import SwiftUI struct StoredHost: Identifiable, Codable, Hashable { @@ -26,7 +27,7 @@ struct StoredHost: Identifiable, Codable, Hashable { @MainActor final class HostStore: ObservableObject { - private static let key = "punktfunk.hosts" + private static let key = DefaultsKey.hosts @Published var hosts: [StoredHost] { didSet { persist() } diff --git a/clients/apple/Sources/PunktfunkClient/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/SessionModel.swift index a976e8e..9768859 100644 --- a/clients/apple/Sources/PunktfunkClient/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/SessionModel.swift @@ -207,9 +207,9 @@ final class SessionModel: ObservableObject { let defaults = UserDefaults.standard let audio = SessionAudio(connection: conn) audio.start( - speakerUID: defaults.string(forKey: "punktfunk.speakerUID") ?? "", - micUID: defaults.string(forKey: "punktfunk.micUID") ?? "", - micEnabled: defaults.object(forKey: "punktfunk.micEnabled") as? Bool ?? true) + speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "", + micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "", + micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true) self.audio = audio // Gamepads: forward GamepadManager's active controller as pad 0 and render the // host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 2343816..1da94c7 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -11,18 +11,18 @@ import SwiftUI @MainActor struct SettingsView: View { @Environment(\.dismiss) private var dismiss - @AppStorage("punktfunk.width") private var width = 1920 - @AppStorage("punktfunk.height") private var height = 1080 - @AppStorage("punktfunk.hz") private var hz = 60 - @AppStorage("punktfunk.compositor") private var compositor = 0 - @AppStorage("punktfunk.gamepadType") private var gamepadType = 0 - @AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0 - @AppStorage("punktfunk.presenter") private var presenter = "stage1" - @AppStorage("punktfunk.micEnabled") private var micEnabled = true + @AppStorage(DefaultsKey.streamWidth) private var width = 1920 + @AppStorage(DefaultsKey.streamHeight) private var height = 1080 + @AppStorage(DefaultsKey.streamHz) private var hz = 60 + @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.micEnabled) private var micEnabled = true @ObservedObject private var gamepads = GamepadManager.shared #if os(macOS) - @AppStorage("punktfunk.speakerUID") private var speakerUID = "" - @AppStorage("punktfunk.micUID") private var micUID = "" + @AppStorage(DefaultsKey.speakerUID) private var speakerUID = "" + @AppStorage(DefaultsKey.micUID) private var micUID = "" @State private var outputDevices: [AudioDevice] = [] @State private var inputDevices: [AudioDevice] = [] #endif diff --git a/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift b/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift index 9d952ec..5ccd59a 100644 --- a/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift @@ -32,10 +32,10 @@ struct SpeedTestSheet: View { @Environment(\.dismiss) private var dismiss let host: StoredHost - @AppStorage("punktfunk.width") private var width = 1920 - @AppStorage("punktfunk.height") private var height = 1080 - @AppStorage("punktfunk.hz") private var hz = 60 - @AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0 + @AppStorage(DefaultsKey.streamWidth) private var width = 1920 + @AppStorage(DefaultsKey.streamHeight) private var height = 1080 + @AppStorage(DefaultsKey.streamHz) private var hz = 60 + @AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0 private enum Phase: Equatable { case connecting diff --git a/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift new file mode 100644 index 0000000..012f56f --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift @@ -0,0 +1,67 @@ +// The streaming overlay HUD: mode + fps/throughput, the capture→client (and, under the stage-2 +// presenter, capture→present) latency lines, the platform input hint, and disconnect. + +import PunktfunkKit +import SwiftUI + +struct StreamHUDView: View { + @ObservedObject var model: SessionModel + let connection: PunktfunkConnection + + var body: some View { + VStack(alignment: .trailing, spacing: 4) { + HStack(spacing: 6) { + Circle() + .fill(Color.accentColor) + .frame(width: 7, height: 7) + Text("\(connection.width)×\(connection.height)@\(connection.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s") + .font(.system(.caption, design: .monospaced)) + } + if model.latencyValid { + // Capture→client-receipt (skew-corrected); excludes the layer's decode+present — + // see LatencyMeter. "(same-host)" when the host didn't answer the skew handshake. + Text("capture→client \(model.latencyP50Ms, specifier: "%.1f")/\(model.latencyP95Ms, specifier: "%.1f") ms p50/p95\(model.latencySkewCorrected ? "" : " (same-host)")") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + if model.presentLatencyValid { + // Capture→present (glass-to-glass, modulo host render→capture) — stage-2 presenter + // only; stage-1's layer presents internally with no per-frame stamp. + Text("capture→present \(model.presentLatencyP50Ms, specifier: "%.1f")/\(model.presentLatencyP95Ms, specifier: "%.1f") ms p50/p95\(model.presentLatencySkewCorrected ? "" : " (same-host)")") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + // While captured the cursor is hidden+frozen, so the button is keyboard-only + // (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again). + #if os(macOS) + Text(model.mouseCaptured + ? "⌘⎋ releases the mouse" + : "Click the stream to capture input") + .font(.caption2) + .foregroundStyle(.secondary) + #elseif os(iOS) + // Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse. + Text(model.mouseCaptured + ? "⌘⎋ releases keyboard & mouse" + : "⌘⎋ captures keyboard & mouse") + .font(.caption2) + .foregroundStyle(.secondary) + #endif + #if os(tvOS) + // No focusable control during play: a focusable button steals the controller's + // A press (the focus engine consumes it before the host sees it). Disconnect is + // the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it. + Text("Press Menu to disconnect") + .font(.caption) + .foregroundStyle(.secondary) + #else + Button("Disconnect (⌘D)") { model.disconnect() } + .font(.caption) + .keyboardShortcut("d", modifiers: .command) + #endif + } + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) + .padding(10) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/TrustCardView.swift b/clients/apple/Sources/PunktfunkClient/TrustCardView.swift new file mode 100644 index 0000000..0adcba2 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/TrustCardView.swift @@ -0,0 +1,80 @@ +// Trust-on-first-use prompt: shown over the live-but-blurred stream when connecting to an +// unpinned host. The user compares the fingerprint with the one the host logged at startup, +// or drops this and runs the PIN pairing ceremony instead. + +import Foundation +import SwiftUI + +struct TrustCardView: View { + let fingerprint: Data + let hostName: String + let onCancel: () -> Void + let onTrust: () -> Void + let onPairInstead: () -> Void + + var body: some View { + VStack(spacing: 14) { + Image(systemName: "lock.shield") + .font(.system(size: 36, weight: .light)) + .foregroundStyle(.tint) + Text("Verify \(hostName)") + .font(.title3.weight(.semibold)) + Text("First connection. Compare this fingerprint with the one " + + "punktfunk-host logged at startup (\u{201C}clients pin this " + + "fingerprint\u{201D}):") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Text(Self.format(fingerprint: fingerprint)) + .font(.system(.callout, design: .monospaced)) + #if !os(tvOS) + .textSelection(.enabled) + #endif + .padding(10) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + HStack(spacing: 12) { + Button("Cancel", role: .cancel, action: onCancel) + #if !os(tvOS) + .keyboardShortcut(.cancelAction) + #endif + Button("Trust & Connect", action: onTrust) + .buttonStyle(.borderedProminent) + #if !os(tvOS) + .keyboardShortcut(.defaultAction) + #endif + } + #if os(iOS) + .controlSize(.large) + #endif + // The verified alternative to eyeballing hex: drop this session (the host + // serves one connection at a time) and run the SPAKE2 PIN ceremony instead. + Button("Pair with PIN instead…", action: onPairInstead) + #if os(macOS) + .buttonStyle(.link) + #else + .buttonStyle(.borderless) + #endif + .font(.callout) + } + .padding(28) + .frame(maxWidth: 440) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18)) + } + + /// 64 hex chars → four groups per line, two lines — easy to eyeball against the log. + private static func format(fingerprint: Data) -> String { + let hex = fingerprint.map { String(format: "%02x", $0) }.joined() + let groups = stride(from: 0, to: hex.count, by: 8).map { i -> String in + let start = hex.index(hex.startIndex, offsetBy: i) + let end = hex.index(start, offsetBy: min(8, hex.count - i)) + return String(hex[start.. [[Element]] { + stride(from: 0, to: count, by: size).map { Array(self[$0.. capacity { readIdx = writeIdx + count - capacity // overflow: drop oldest } diff --git a/clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift index 17a9476..6c8bc2f 100644 --- a/clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift @@ -12,6 +12,16 @@ import AVFoundation import Foundation import QuartzCore +/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view +/// directly makes a `view → link → view` cycle that only `invalidate()` breaks — if a teardown +/// is ever missed the view leaks and keeps ticking. This proxy holds the handler weakly, so the +/// view can deallocate and its `deinit` invalidate the link. +public final class DisplayLinkProxy: NSObject { + private let onTick: (CADisplayLink) -> Void + public init(_ onTick: @escaping (CADisplayLink) -> Void) { self.onTick = onTick } + @objc public func tick(_ link: CADisplayLink) { onTick(link) } +} + /// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest /// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded. private final class ReadyRing: @unchecked Sendable { diff --git a/clients/apple/Sources/PunktfunkKit/StreamView.swift b/clients/apple/Sources/PunktfunkKit/StreamView.swift index 27b5236..91bac85 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamView.swift @@ -434,7 +434,7 @@ public final class StreamLayerView: NSView { // 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: "punktfunk.presenter") == "stage2", + if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2", let meter = presentMeter, let pipeline = Stage2Pipeline(presentMeter: meter) { startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) @@ -455,17 +455,22 @@ public final class StreamLayerView: NSView { onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)? ) { let metal = pipeline.layer - displayLayer.addSublayer(metal) // contentsScale + frame set in layoutMetalLayer() + // The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which sits + // idle (un-enqueued) in stage-2. contentsScale + frame are set in layoutMetalLayer(). + displayLayer.addSublayer(metal) metalLayer = metal stage2 = pipeline layoutMetalLayer() - let link = displayLink(target: self, selector: #selector(stage2Tick(_:))) + // Weak-proxy target so the link doesn't form a retain cycle with the view (see + // DisplayLinkProxy) — the link retains the proxy; the proxy holds the view weakly. + let proxy = DisplayLinkProxy { [weak self] link in self?.stage2Tick(link) } + let link = displayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick(_:))) link.add(to: .main, forMode: .common) stage2Link = link pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) } - @objc private func stage2Tick(_ link: CADisplayLink) { + private func stage2Tick(_ link: CADisplayLink) { stage2?.renderTick( targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp)) } @@ -523,6 +528,7 @@ public final class StreamLayerView: NSView { appObservers.forEach(NotificationCenter.default.removeObserver(_:)) windowObservers.forEach(NotificationCenter.default.removeObserver(_:)) pump?.stop() + teardownStage2() // invalidate the display link + stop the pipeline if stop() was missed } } #endif diff --git a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift index caa4003..9268376 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift @@ -218,7 +218,7 @@ public final class StreamViewController: UIViewController { // 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: "punktfunk.presenter") == "stage2", + if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2", let meter = presentMeter, let pipeline = Stage2Pipeline(presentMeter: meter) { startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) @@ -294,17 +294,21 @@ public final class StreamViewController: UIViewController { ) { let metal = pipeline.layer metal.contentsScale = streamView.contentScaleFactor + // Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base. streamView.layer.addSublayer(metal) metalLayer = metal stage2 = pipeline layoutMetalLayer() - let link = CADisplayLink(target: self, selector: #selector(stage2Tick(_:))) + // Weak-proxy target so the link doesn't retain-cycle with the controller (see + // DisplayLinkProxy) — the link retains the proxy; the proxy holds self weakly. + let proxy = DisplayLinkProxy { [weak self] link in self?.stage2Tick(link) } + let link = CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick(_:))) link.add(to: .main, forMode: .common) stage2Link = link pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) } - @objc private func stage2Tick(_ link: CADisplayLink) { + private func stage2Tick(_ link: CADisplayLink) { stage2?.renderTick( targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp)) } @@ -394,6 +398,7 @@ public final class StreamViewController: UIViewController { deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) pump?.stop() + teardownStage2() // invalidate the display link + stop the pipeline if stop() was missed } }