diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 56b16525..61020f82 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -87,6 +87,10 @@ struct ContentView: View { // with no (extended) controller attached tvOS falls back to HomeView as before. @ObservedObject private var gamepadManager = GamepadManager.shared @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + /// Auto-wake on connect (Settings → General). On (default): a dial to an offline saved host + /// fires Wake-on-LAN up front and falls into the "Waking…" wait if the dial fails. Off: connects + /// go straight through with no wake. The explicit "Wake Host" action is unaffected either way. + @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true private var gamepadUIActive: Bool { GamepadUIEnvironment.isActive( gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled) @@ -268,6 +272,7 @@ struct ContentView: View { ConnectOverlay( connectingHostName: connectingOverlayName, waker: waker, + gamepadUI: gamepadUIActive, onCancelConnect: { model.disconnect() }) } } @@ -584,7 +589,8 @@ struct ContentView: View { // packet up front, so a genuinely-asleep host is waking while the connect times out; only // when that dial FAILS do we fall into the visible "Waking…" wait — a cold box takes far // longer to boot than a connect will sit — and redial once it's back on mDNS. - if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) { + if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, + !host.wakeMacs.isEmpty, !discovery.advertises(host) { discovery.start() // so the wake-wait can observe it reappear startSessionDirect( host, launchID: launchID, allowTofu: allowTofu, @@ -641,7 +647,9 @@ struct ContentView: View { private func prepareWake(for host: StoredHost) { if let live = discovery.hosts.first(where: { host.matches($0) }) { store.updateMacs(host.id, macs: live.macAddresses) // learn — on every platform - } else if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty { + } else if autoWakeEnabled, PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty { + // Auto-wake only: fire the up-front packet so a genuinely-asleep host is booting while the + // dial times out. With auto-wake off, connects go straight through (no packet). let macs = host.wakeMacs let ip = host.address DispatchQueue.global(qos: .userInitiated).async { diff --git a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift index 58c3e469..b8246e98 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift @@ -1,7 +1,7 @@ -// The unified full-screen "getting you connected" takeover — one look for BOTH phases of reaching a -// host, so the user gets feedback the instant they pick one and it flows seamlessly into a wake if -// the host turns out to be asleep. The Apple mirror of the Android client's `ConnectOverlay` and the -// shared console UI's connect/wake takeover; it replaces the old centered-card `WakeOverlay`. +// The unified "getting you connected" overlay — one look for BOTH phases of reaching a host, so the +// user gets feedback the instant they pick one and it flows seamlessly into a wake if the host turns +// out to be asleep. The Apple mirror of the Android client's `ConnectOverlay` and the shared console +// UI's connect/wake takeover; it replaces the old centered-card `WakeOverlay`. // // - Connecting (`connectingHostName` non-nil): the dial is in flight. Shown immediately on activate // so a host that takes a beat to answer no longer looks like nothing happened. @@ -9,10 +9,15 @@ // Wake-on-LAN and waiting for it to advertise again, escalating to a retry/cancel prompt on // timeout. // +// Presentation is mode-aware: the gamepad ("console") UI gets a full-screen aurora takeover — the +// same living backdrop the console home wears, so it reads as a deliberate 10-foot moment; the +// default touch/desktop UI gets a Liquid Glass modal over a dim scrim, which sits right at home among +// the app's other floating surfaces (the trust card, the HUD) instead of a full-screen aurora that +// looked out of place there. +// // The two phases hand off within a single view update (HostWaker clears `waking` and starts the -// connect in the same MainActor step), so the overlay never blinks between them. Presented over BOTH -// the touch and gamepad home; it swallows input to the screen behind it, and on iOS/macOS the pad -// drives it (B cancels, A retries once timed out) while the buttons work for a pointer / tvOS focus. +// connect in the same MainActor step), so the overlay never blinks between them. It swallows input to +// the screen behind it, and on iOS/macOS the pad drives it (B cancels, A retries once timed out). import PunktfunkKit import SwiftUI @@ -22,6 +27,9 @@ struct ConnectOverlay: View { /// passes nil here). Drives the "Connecting…" phase. let connectingHostName: String? @ObservedObject var waker: HostWaker + /// The console launcher is up → full-screen aurora takeover; otherwise the default UI's Liquid + /// Glass modal. + var gamepadUI: Bool /// Cancel a dial in flight — tears down the (uncancelable) connect and returns the UI; the late /// result is discarded by SessionModel's connect guard. var onCancelConnect: () -> Void @@ -42,13 +50,25 @@ struct ConnectOverlay: View { var body: some View { if let phase { ZStack { - // Opaque aurora — the same living backdrop the console home wears, so the takeover - // reads as a deliberate full-screen moment rather than a card popping up. - Color.black.ignoresSafeArea() - GamepadScreenBackground().ignoresSafeArea() - // Swallow taps so the home behind can't be touched through the takeover. - Color.clear.contentShape(Rectangle()).onTapGesture {} - content(phase).padding(40).frame(maxWidth: 460) + if gamepadUI { + // Console: an opaque, living aurora over everything. + Color.black.ignoresSafeArea() + GamepadScreenBackground().ignoresSafeArea() + Color.clear.contentShape(Rectangle()).onTapGesture {} + content(phase).padding(40).frame(maxWidth: 460) + } else { + // Default UI: a Liquid Glass modal over a dim scrim. + Rectangle().fill(.black.opacity(0.5)).ignoresSafeArea() + .contentShape(Rectangle()).onTapGesture {} + content(phase) + .padding(28) + .frame(maxWidth: 380) + .glassBackground(RoundedRectangle(cornerRadius: 26, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 1)) + .padding(40) + } } .environment(\.colorScheme, .dark) .transition(.opacity) @@ -59,24 +79,27 @@ struct ConnectOverlay: View { } @ViewBuilder private func content(_ phase: Phase) -> some View { - VStack(spacing: 16) { + // The takeover carries larger type than the compact modal. + let titleSize: CGFloat = gamepadUI ? 24 : 19 + let bodySize: CGFloat = gamepadUI ? 14 : 13 + VStack(spacing: gamepadUI ? 16 : 14) { switch phase { case .connecting(let name): ProgressView().controlSize(.large).tint(.white) Text("Connecting to \(name)") - .font(.geist(24, .bold, relativeTo: .title2)).foregroundStyle(.white) + .font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white) .multilineTextAlignment(.center) Text("Establishing a secure connection…") - .font(.geist(14, relativeTo: .callout)).foregroundStyle(.white.opacity(0.65)) + .font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6)) Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6) case .waking(let w) where w.timedOut: Image(systemName: "moon.zzz.fill") - .font(.system(size: 40)).foregroundStyle(.white.opacity(0.9)) + .font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9)) Text("\(w.hostName) didn't wake") - .font(.geist(24, .bold, relativeTo: .title2)).foregroundStyle(.white) + .font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white) .multilineTextAlignment(.center) Text("It may still be booting, or it's powered off / off this network.") - .font(.geist(14, relativeTo: .callout)).foregroundStyle(.white.opacity(0.65)) + .font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6)) .multilineTextAlignment(.center) HStack(spacing: 12) { Button("Cancel") { waker.cancel() }.buttonStyle(.bordered) @@ -86,10 +109,10 @@ struct ConnectOverlay: View { case .waking(let w): ProgressView().controlSize(.large).tint(.white) Text("Waking \(w.hostName)…") - .font(.geist(24, .bold, relativeTo: .title2)).foregroundStyle(.white) + .font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white) .multilineTextAlignment(.center) Text("Waiting for it to come online · \(w.seconds)s") - .font(.geistFixed(14)).foregroundStyle(.white.opacity(0.65)).monospacedDigit() + .font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit() // A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel". Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() } .buttonStyle(.bordered).padding(.top, 6) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index b984a8d2..a0d63d3f 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -65,6 +65,9 @@ struct GamepadHomeView: View { /// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the /// Settings "Game library" toggle opts out). @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true + /// Auto-wake on connect (default ON) — when off, activating an offline host just dials (no wake), + /// so the tile drops its "Wake & Connect" affordance for a plain "Connect". + @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true #if os(iOS) /// `.compact` in a landscape phone window — drives tighter chrome so everything still fits. @Environment(\.verticalSizeClass) private var vSizeClass @@ -259,7 +262,7 @@ struct GamepadHomeView: View { isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, filled: true, hasLibrary: true, - canWake: PunktfunkConnection.wakeOnLANAvailable + canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable && !discovery.advertises(host) && !store.probedOnline.contains(host.id) && !host.wakeMacs.isEmpty, activate: { connect(host) }) diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index f1d07d01..2b980e82 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -58,6 +58,16 @@ enum ShotScenes { ShotScene(name: "09c-wake-timed-out", orientation: .natural, colorScheme: .dark) { AnyView(ShotConnect(kind: .timedOut)) }, + // The default-UI presentation (Liquid Glass modal over the touch grid) of the same phases. + ShotScene(name: "09d-connecting-modal", orientation: .natural, colorScheme: .dark) { + AnyView(ShotConnect(kind: .connecting, gamepadUI: false)) + }, + ShotScene(name: "09e-waking-modal", orientation: .natural, colorScheme: .dark) { + AnyView(ShotConnect(kind: .waking, gamepadUI: false)) + }, + ShotScene(name: "09f-wake-timed-out-modal", orientation: .natural, colorScheme: .dark) { + AnyView(ShotConnect(kind: .timedOut, gamepadUI: false)) + }, ] #endif scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) { @@ -143,11 +153,14 @@ private struct ShotGamepadAddHost: View { var body: some View { GamepadAddHostView(onAdd: { _ in }) } } -/// The unified full-screen connect takeover (the real `ConnectOverlay`) in each phase — instant -/// "Connecting…" feedback, the "Waking…" wait, and the wake-timed-out prompt — over the gamepad home. +/// The unified connect overlay (the real `ConnectOverlay`) in each phase — instant "Connecting…" +/// feedback, the "Waking…" wait, and the wake-timed-out prompt. `gamepadUI` picks the presentation: +/// the console's full-screen aurora takeover over the gamepad home, or the default UI's Liquid Glass +/// modal over the touch host grid. private struct ShotConnect: View { enum Kind { case connecting, waking, timedOut } let kind: Kind + var gamepadUI = true @StateObject private var store = ShotMock.hostStore() @StateObject private var model = SessionModel() @@ -155,30 +168,38 @@ private struct ShotConnect: View { @StateObject private var waker = HostWaker() var body: some View { - GamepadHomeView( - store: store, model: model, discovery: discovery, - libraryTarget: .constant(nil), waker: waker, - connect: { _ in }, connectDiscovered: { _ in } - ) - .overlay { - ConnectOverlay( - connectingHostName: kind == .connecting ? "Battlestation" : nil, - waker: waker, - onCancelConnect: {}) - } - .onAppear { - switch kind { - case .connecting: - break - case .waking: - waker.debugSet(.init( - hostID: store.hosts.first?.id ?? UUID(), - hostName: "Battlestation", connectsAfter: true, seconds: 14)) - case .timedOut: - waker.debugSet(.init( - hostID: store.hosts.first?.id ?? UUID(), - hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true)) + backdrop + .overlay { + ConnectOverlay( + connectingHostName: kind == .connecting ? "Battlestation" : nil, + waker: waker, + gamepadUI: gamepadUI, + onCancelConnect: {}) } + .onAppear { + switch kind { + case .connecting: + break + case .waking: + waker.debugSet(.init( + hostID: store.hosts.first?.id ?? UUID(), + hostName: "Battlestation", connectsAfter: true, seconds: 14)) + case .timedOut: + waker.debugSet(.init( + hostID: store.hosts.first?.id ?? UUID(), + hostName: "Battlestation", connectsAfter: true, seconds: 90, timedOut: true)) + } + } + } + + @ViewBuilder private var backdrop: some View { + if gamepadUI { + GamepadHomeView( + store: store, model: model, discovery: discovery, + libraryTarget: .constant(nil), waker: waker, + connect: { _ in }, connectDiscovered: { _ in }) + } else { + ShotHome() } } } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index b7bde578..80a8bb48 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -36,6 +36,7 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + @AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true @AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault @ObservedObject private var gamepads = GamepadManager.shared @@ -258,6 +259,11 @@ struct GamepadSettingsView: View { + "available on the host.", options: SettingsOptions.compositors, current: compositor ) { compositor = $0 }, + toggleRow( + id: "autoWake", icon: "power", label: "Auto-wake on connect", + detail: "Send Wake-on-LAN to a sleeping saved host and wait for it before " + + "streaming. Off connects straight through.", + value: $autoWakeEnabled), choiceRow( id: "codec", header: "Video", icon: "film", label: "Video codec", diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index a4cf536e..6d2c10b5 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -42,9 +42,10 @@ extension SettingsView { } footer: { Text(matchWindow ? "The stream follows this window — the host resizes its virtual output to match " - + "as you resize, no scaling. \(Self.bitrateFooter)" - : "The host creates a virtual output at exactly this mode — " - + "native resolution, no scaling. \(Self.bitrateFooter)") + + "as you resize, so the picture stays pixel-exact (1:1) with no scaling. " + + "\(Self.bitrateFooter)" + : "The host creates a virtual output at exactly this mode — native resolution, but " + + "a window that isn't this size is scaled to fit. \(Self.bitrateFooter)") .font(.geist(12, relativeTo: .caption)) .foregroundStyle(.secondary) } @@ -294,6 +295,24 @@ extension SettingsView { } } + /// Auto-wake on connect — fire Wake-on-LAN + wait for a sleeping saved host to come back before + /// giving up. Now available on every platform (the iOS/tvOS multicast entitlement is granted). + @ViewBuilder var wakeSection: some View { + Section { + Toggle("Auto-wake on connect", isOn: $autoWakeEnabled) + } header: { + Text("Wake-on-LAN") + } footer: { + Text("Connecting to a saved host that isn't on the network yet sends a Wake-on-LAN " + + "packet and waits for it to come back before streaming. Turn off if a host that's " + + "already on just isn't visible here (e.g. over a VPN), so connects go straight " + + "through instead of waiting out the wake. A host's “Wake” action still works either " + + "way.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + @ViewBuilder var windowSection: some View { #if os(macOS) Section { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index f53e1694..73015bb5 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -21,7 +21,10 @@ struct SettingsView: View { @AppStorage(DefaultsKey.streamWidth) var width = 1920 @AppStorage(DefaultsKey.streamHeight) var height = 1080 @AppStorage(DefaultsKey.streamHz) var hz = 60 - @AppStorage(DefaultsKey.matchWindow) var matchWindow = false + // Default ON: a windowed session streams at the window's native pixels (1:1, no scaling) so it + // stays pixel-exact instead of the presenter resampling a fixed-mode frame into the window. + // Off falls back to the explicit mode below (fixed output, scaled to non-matching windows). + @AppStorage(DefaultsKey.matchWindow) var matchWindow = true @AppStorage(DefaultsKey.compositor) var compositor = 0 @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 @@ -45,6 +48,7 @@ struct SettingsView: View { @AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue @ObservedObject var gamepads = GamepadManager.shared @AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true + @AppStorage(DefaultsKey.autoWake) var autoWakeEnabled = true #if DEBUG && !os(tvOS) @State var showControllerTest = false #endif @@ -106,6 +110,7 @@ struct SettingsView: View { Form { streamModeSection compositorSection + wakeSection } .formStyle(.grouped) .tabItem { Label("General", systemImage: "gearshape") } @@ -235,6 +240,7 @@ struct SettingsView: View { streamModeSection pointerSection compositorSection + wakeSection } .formStyle(.grouped) .navigationTitle("General") @@ -305,6 +311,10 @@ struct SettingsView: View { Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" }) } + private var autoWakeEnabledTag: Binding { + Binding(get: { autoWakeEnabled ? "on" : "off" }, set: { autoWakeEnabled = $0 == "on" }) + } + private var tvBody: some View { let currentTag = "\(width)x\(height)x\(hz)" let bounds = UIScreen.main.nativeBounds @@ -344,9 +354,13 @@ struct SettingsView: View { TVSelectionRow( title: "10-bit HDR", options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag) + TVSelectionRow( + title: "Auto-wake on connect", + options: [("On", "on"), ("Off", "off")], selection: autoWakeEnabledTag) 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.") + + "is honored only if available on the host. Auto-wake sends Wake-on-LAN to a " + + "sleeping saved host and waits for it before streaming.") .font(.geist(20, relativeTo: .caption)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) diff --git a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift index 33955d4d..65073f4b 100644 --- a/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift @@ -97,6 +97,12 @@ public enum DefaultsKey { /// layout (the console launcher, gamepad-navigable settings, a coverflow-style library) /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" + /// Auto-wake on connect: when connecting to a saved host that isn't advertising on mDNS, fire + /// Wake-on-LAN and, if the dial fails, wait for it to come back before retrying (the "Waking…" + /// overlay). On by default. Turn off if a host that's already on just isn't seen on mDNS (a + /// routed/VPN host), so connects go straight through instead of waiting out the wake timeout. + /// The explicit "Wake Host" action stays available regardless. Read by ContentView.startSession. + public static let autoWake = "punktfunk.autoWake" } extension Notification.Name { diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index e11db891..71c299da 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -70,6 +70,15 @@ final class SessionPresenter { private var stage2Link: CADisplayLink? private var metalLayer: CAMetalLayer? private var connection: PunktfunkConnection? + /// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's + /// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to + /// `connection.currentMode()`, which (a) lags a mid-stream resize — it only updates on the + /// `Reconfigured` ack, and a resize-END produces no bounds change to re-run `layout` afterward — + /// and (b) can disagree with what the host actually DELIVERED (Windows corrective-ack falls back + /// to an advertised mode). The pixels we're drawing are the only correct aspect source; a wrong + /// one here is the "black bars + stretched" resize artifact. nil until the first frame → `layout` + /// falls back to `currentMode()`. Main-thread only. + private var contentSize: CGSize? /// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer: /// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer @@ -184,11 +193,18 @@ final class SessionPresenter { guard let metalLayer, let connection else { return } let mode = connection.currentMode() syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh - let fit: CGRect = (mode.width > 0 && mode.height > 0) - ? AVMakeRect( - aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), - insideRect: bounds) - : bounds + // Aspect source: the ACTUAL decoded dims when known (survives a lagging `currentMode()` and a + // host that delivered a different size than requested), else the negotiated mode. The shader + // stretches the frame across the WHOLE drawable, so this rect's aspect is the only thing that + // keeps the picture undistorted — a stale aspect here is the post-resize black-bars+stretch. + let aspect: CGSize? = { + if let c = contentSize, c.width > 0, c.height > 0 { return c } + if mode.width > 0, mode.height > 0 { + return CGSize(width: Int(mode.width), height: Int(mode.height)) + } + return nil + }() + let fit: CGRect = aspect.map { AVMakeRect(aspectRatio: $0, insideRect: bounds) } ?? bounds // No implicit resize animation; contentsScale tracks the view's backing/display scale. CATransaction.begin() CATransaction.setDisableActions(true) @@ -209,10 +225,20 @@ final class SessionPresenter { #endif } + /// Record the decoded frame's real dimensions (the view hops the pump's `onDecodedSize` to main + /// and calls this) so `layout` aspect-fits to what's actually on screen instead of the possibly- + /// stale `currentMode()`. Only stores — the caller re-runs `layout` right after, because a + /// resize-END produces no bounds change to trigger one. No-op on a zero/unchanged size. + func setContentSize(_ size: CGSize) { + guard size.width > 0, size.height > 0, size != contentSize else { return } + contentSize = size + } + /// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the /// stage-2 layer + link. Does not close the connection — that stays with whoever owns it. /// Idempotent. func stop() { + contentSize = nil // a new session re-derives it from its first frame pump?.stop() pump = nil stage2Link?.invalidate() diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 2d0f7db1..97bc7ecb 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -176,8 +176,14 @@ public final class StreamLayerView: NSView { private let presenter = SessionPresenter() public private(set) var connection: PunktfunkConnection? /// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow` - /// setting is on; fed the view's physical-pixel size on every relayout. + /// setting is on (DEFAULT on, for pixel-exact windowed streaming); fed the view's physical-pixel + /// size on every relayout so the host mode tracks the window (1:1, no presenter resample). private var matchFollower: MatchWindowFollower? + /// Last decoded frame size fed into the presenter's aspect-fit. A new-mode IDR after a resize + /// re-fits the metal sublayer to the REAL content aspect here — `layout()` only re-runs on a + /// bounds change and a resize-END has none, so without this the layer keeps its pre-resize aspect + /// and the shader stretches the new frame into it (black bars + squish). Main-thread only. + private var lastDecodedContentSize: CGSize? private let cursorCapture = CursorCapture() private var inputCapture: InputCapture? private var appObservers: [NSObjectProtocol] = [] @@ -638,6 +644,10 @@ public final class StreamLayerView: NSView { // (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by // default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from // NSView.displayLink so it tracks the display this view is on. + // Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content + // aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a + // size CHANGE (first frame + each resolved resize), so this is rare, not per-frame. + let overlayDecodedSize = onDecodedSize presenter.start( connection: connection, baseLayer: displayLayer, @@ -647,13 +657,19 @@ public final class StreamLayerView: NSView { makeDisplayLink: { displayLink(target: $0, selector: $1) }, onFrame: onFrame, onSessionEnd: onSessionEnd, - onDecodedSize: onDecodedSize) // resize overlay END signal (new-mode IDR dims) - // Match-window (C3): follow the window's pixel size when the setting is on. Latched at - // session start (mirrors the other clients); the first real `layout()` feeds the initial - // size, so the stream converges to the window even if the connect used the explicit mode. + onDecodedSize: { [weak self] w, h in // resize overlay END signal (new-mode IDR dims) + DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) } + overlayDecodedSize?(w, h) + }) + // Match-window (C3): follow the window's pixel size — DEFAULT ON, so a windowed session + // streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into a + // non-matching window. The first real `layout()` feeds the initial size, so the stream + // converges to the window even though the connect used the explicit/display mode; entering + // fullscreen reports the full-display px, restoring a native-res 1:1 present there too. + // `?? true` so an unset default matches the Settings toggle (which also defaults on). let follower = MatchWindowFollower( connection: connection, - enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow)) + enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true) follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower) matchFollower = follower layoutPresenter() @@ -679,6 +695,18 @@ public final class StreamLayerView: NSView { layoutPresenter() // backing scale changed (e.g. moved to a non-retina display) } + /// A new decoded size landed (a new-mode IDR after a resize, or the session's first frame): push + /// it to the presenter's aspect-fit and re-layout NOW. A resize-END triggers no `layout()`, so + /// this is what makes the metal sublayer track the new content aspect instead of stretching the + /// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread. + private func noteDecodedContentSize(width: Int, height: Int) { + let size = CGSize(width: width, height: height) + guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return } + lastDecodedContentSize = size + presenter.setContentSize(size) + layoutPresenter() + } + /// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with /// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump). public func stop() { @@ -688,6 +716,7 @@ public final class StreamLayerView: NSView { inputCapture = nil presenter.stop() matchFollower = nil + lastDecodedContentSize = nil // the next session re-derives it from its first frame connection = nil } diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index f79d002b..b912a99a 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -158,9 +158,10 @@ public final class StreamViewController: StreamViewControllerBase { /// mouse/keyboard stay released after navigating out and nothing re-grabs them. private var wasCapturedOnResign = false /// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow` - /// setting is on; fed the view's physical-pixel size from `viewDidLayoutSubviews` so an iPad - /// Stage Manager / Split View scene resize renegotiates the host mode. iOS only (iPhone - /// naturally no-ops fullscreen; tvOS drives display modes via AVDisplayManager instead). + /// setting is on (DEFAULT on, for pixel-exact scene streaming); fed the view's physical-pixel + /// size from `viewDidLayoutSubviews` so an iPad Stage Manager / Split View scene resize + /// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops + /// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead). private var matchFollower: MatchWindowFollower? #endif @@ -183,6 +184,11 @@ public final class StreamViewController: StreamViewControllerBase { /// Resize-overlay END: the presenter reports the coded dims of each new-mode IDR here, so the /// overlay clears when a frame at the requested size actually decodes. var onDecodedSize: (@Sendable (Int, Int) -> Void)? + /// Last decoded size fed into the presenter's aspect-fit. A new-mode IDR (an iPad scene resize, + /// or a tvOS AVDisplayManager mode switch) re-fits the metal sublayer to the REAL content aspect + /// here — `viewDidLayoutSubviews` only re-runs on a bounds change, which a resize-END lacks, so + /// without this the layer keeps its pre-resize aspect and stretches the new frame into it. Main. + private var lastDecodedContentSize: CGSize? var captureEnabled = true { didSet { @@ -349,12 +355,14 @@ public final class StreamViewController: StreamViewControllerBase { } capture.start() inputCapture = capture - // Match-window (C3): follow the scene's pixel size when the setting is on. Latched at - // session start (mirrors the other clients); `viewDidLayoutSubviews` feeds it — covers - // Stage Manager / Split View resizes and rotation. iPhone fullscreen naturally no-ops. + // Match-window (C3): follow the scene's pixel size — DEFAULT ON, so a resizable iPad scene + // streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into it. + // `viewDidLayoutSubviews` feeds it — covers Stage Manager / Split View resizes and rotation. + // iPhone is a fixed full-screen scene, so this naturally no-ops (reports the device mode). + // `?? true` so an unset default matches the Settings toggle (which also defaults on). let follower = MatchWindowFollower( connection: connection, - enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow)) + enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true) follower.onResizeTarget = onResizeTarget matchFollower = follower #endif @@ -362,6 +370,10 @@ public final class StreamViewController: StreamViewControllerBase { // Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2 // (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by // default, the stage-1 pump as the Metal-missing / DEBUG fallback. + // Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content + // aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a + // size CHANGE (first frame + each resolved resize), so this is rare, not per-frame. + let overlayDecodedSize = onDecodedSize presenter.start( connection: connection, baseLayer: streamView.displayLayer, @@ -371,7 +383,10 @@ public final class StreamViewController: StreamViewControllerBase { makeDisplayLink: { CADisplayLink(target: $0, selector: $1) }, onFrame: onFrame, onSessionEnd: onSessionEnd, - onDecodedSize: onDecodedSize) + onDecodedSize: { [weak self] w, h in + DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) } + overlayDecodedSize?(w, h) + }) layoutMetalLayer() #if os(iOS) @@ -451,6 +466,7 @@ public final class StreamViewController: StreamViewControllerBase { sessionDisplayManager = nil #endif presenter.stop() + lastDecodedContentSize = nil // the next session re-derives it from its first frame connection = nil } @@ -527,6 +543,18 @@ public final class StreamViewController: StreamViewControllerBase { presenter.layout(in: streamView.bounds, contentsScale: renderScale) } + /// A new decoded size landed (a scene/mode resize's new IDR, or the first frame): push it to the + /// presenter's aspect-fit and re-layout NOW. A resize-END triggers no `viewDidLayoutSubviews`, so + /// this is what makes the metal sublayer track the new content aspect instead of stretching the + /// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread. + private func noteDecodedContentSize(width: Int, height: Int) { + let size = CGSize(width: width, height: height) + guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return } + lastDecodedContentSize = size + presenter.setContentSize(size) + layoutMetalLayer() + } + #if os(iOS) private func setCaptured(_ on: Bool) { if on {