diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 1b0c8c23..849b7b72 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -327,12 +327,25 @@ struct ContentView: View { }() return ZStack { stream(captureEnabled: pendingFingerprint == nil) - .blur(radius: pendingFingerprint != nil ? 32 : 0) + // Blur the live stream during the trust prompt (heavy) and during a resize (lighter + // — the deliberate "hold on" while the host rebuilds its pipeline and the decoder + // re-inits on the new-mode IDR). Only the resize blur animates; the trust blur snaps + // as before (its own overlay handles the transition). + .blur(radius: pendingFingerprint != nil ? 32 : (model.resizing ? 16 : 0)) + .animation(.easeInOut(duration: 0.22), value: model.resizing) .overlay { if pendingFingerprint != nil { Color.black.opacity(0.45) } } + // The resize spinner rides over the (blurred) stream; suppressed under the trust + // prompt, which owns the screen. It never hit-tests, so window-drag resizes keep + // steering and the next click still reaches the stream. + .overlay { + if pendingFingerprint == nil { + ResizeIndicatorView(active: model.resizing) + } + } if let fp = pendingFingerprint { TrustCardView( fingerprint: fp, @@ -410,6 +423,16 @@ struct ContentView: View { onSessionEnd: { [weak model] in Task { @MainActor in model?.sessionEnded() } }, + // Resize overlay START — the follower is main-actor, so this drives the blur + // + spinner synchronously the instant the window differs from the live mode. + onResizeTarget: { [weak model] w, h in + model?.resizeTargeted(width: w, height: h) + }, + // Resize overlay END — the coded dims of each new-mode IDR, reported from the + // decode pump thread; hop to the main actor to clear the overlay. + onDecodedSize: { [weak model] w, h in + Task { @MainActor in model?.resizeDecoded(width: w, height: h) } + }, endToEndMeter: model.endToEnd, decodeMeter: model.decodeStage, displayMeter: model.displayStage diff --git a/clients/apple/Sources/PunktfunkClient/Session/ResizeIndicatorView.swift b/clients/apple/Sources/PunktfunkClient/Session/ResizeIndicatorView.swift new file mode 100644 index 00000000..373a4150 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Session/ResizeIndicatorView.swift @@ -0,0 +1,41 @@ +// The resize overlay (design/midstream-resolution-resize.md — client resize UX). A Match-window +// resize renegotiates the host's virtual display + encoder and re-inits the local VideoToolbox +// decoder on the first new-mode IDR — an unavoidable sub-second gap where the last frame lingers, +// briefly freezes, or the picture pops to the new geometry. Rather than let that read as a stutter, +// we make it DELIBERATE: the caller blurs the live stream and this centered spinner + caption +// acknowledges the transition. It clears the instant a frame at the requested size decodes (the +// `onDecodedSize` END signal) or on the follower's safety timeout — see `SessionModel.resizing`. +// +// Floating overlay, never a hit-test target: input keeps flowing to the stream underneath so a +// resize the user triggers by dragging the window never swallows their next click. + +import PunktfunkKit +import SwiftUI + +struct ResizeIndicatorView: View { + /// Mirrors `SessionModel.resizing`; the fade in/out is driven off this. + let active: Bool + + var body: some View { + ZStack { + if active { + VStack(spacing: 12) { + ProgressView().controlSize(.large).tint(.white) + Text("Resizing…") + .font(.geist(15, .medium, relativeTo: .callout)) + .foregroundStyle(.white.opacity(0.85)) + } + .padding(.horizontal, 30) + .padding(.vertical, 24) + .glassBackground(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 1)) + .transition(.opacity.combined(with: .scale(scale: 0.92))) + } + } + .environment(\.colorScheme, .dark) // the spinner + glass read over any frame + .animation(.easeInOut(duration: 0.22), value: active) + .allowsHitTesting(false) // the stream keeps receiving input the whole time + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 9e0df31e..4c524714 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -109,6 +109,16 @@ final class SessionModel: ObservableObject { /// Mirrors StreamView's capture state (it owns the input capture; this drives the /// HUD's "click to capture" / "⌘⎋ releases" hint). @Published var mouseCaptured = false + /// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the + /// instant a Match-window resize starts steering toward a new size until a frame at that size + /// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay + /// reads as a deliberate, acknowledged transition instead of a stutter. Pure state lives in + /// `ResizeIndicator`; this mirrors its `active` for SwiftUI. + @Published private(set) var resizing = false + /// START = follower steering (main actor), END = a new-mode IDR's coded dims (decode pump, + /// hopped to main), TIMEOUT = safety net for a rejected/capped switch that never yields a + /// differently-sized frame. Ticked from the 1 Hz stats timer. + private var resizeIndicator = ResizeIndicator() let meter = FrameMeter() /// Capture→received (the host+network stage), fed per AU at receipt by the stream view's @@ -364,6 +374,8 @@ final class SessionModel: ObservableObject { lostFrames = 0 lostPct = 0 mouseCaptured = false + resizing = false + resizeIndicator = ResizeIndicator() // no stale target/timer into the next session } /// Called (via the main actor) when the pump hits end-of-session. @@ -374,6 +386,23 @@ final class SessionModel: ObservableObject { errorMessage = "Session ended by \(name)." } + /// Resize overlay START (main actor — from the Match-window follower's `onResizeTarget`): the + /// window began differing from the live mode, so a `Reconfigure` toward `(width, height)` is + /// imminent. Show the blur+spinner immediately, before the debounced request even leaves. + func resizeTargeted(width: UInt32, height: UInt32) { + resizeIndicator.steering( + width: width, height: height, now: Date().timeIntervalSinceReferenceDate) + resizing = resizeIndicator.active + } + + /// Resize overlay END (main actor — hopped from the decode pump's `onDecodedSize`): a new-mode + /// IDR decoded at `(width, height)`. Clears the overlay only when that matches the size we're + /// steering to (a same-size loss-recovery IDR, or the initial connect IDR, is a no-op). + func resizeDecoded(width: Int, height: Int) { + resizeIndicator.decoded(width: UInt32(max(width, 0)), height: UInt32(max(height, 0))) + resizing = resizeIndicator.active + } + private func beginStreaming() { guard let conn = connection else { return } // Input capture itself is owned by StreamView (engaged by the captureEnabled @@ -417,6 +446,11 @@ final class SessionModel: ObservableObject { let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in guard let self else { return } Task { @MainActor in + // Resize-overlay safety net: clear a stuck overlay when a targeted size never + // decodes (a rejected/capped switch). The decoded-frame END clears it promptly on + // success; this only fires after the timeout. + self.resizeIndicator.tick(now: Date().timeIntervalSinceReferenceDate) + self.resizing = self.resizeIndicator.active let (frames, bytes, total) = self.meter.drain() self.fps = frames self.mbps = Double(bytes) * 8 / 1_000_000 diff --git a/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift b/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift index 2a2ea418..6907af56 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MatchWindowFollower.swift @@ -57,6 +57,16 @@ public final class MatchWindowFollower { private var pendingSize: (width: Int, height: Int)? private var lastRequested: (width: UInt32, height: UInt32)? private var lastRequestAt: Date? + /// The last size we reported via [`onResizeTarget`] — dedups the per-layout stream of a drag so + /// the UI is notified once per distinct target, and reset to `nil` when the window is back in + /// sync with the live mode (so a later resize re-reports). + private var lastSteered: (width: UInt32, height: UInt32)? + + /// Fired (on the main actor) the instant the window starts differing from the live mode — i.e. + /// a resize is under way and a `Reconfigure` for `(width, height)` is imminent. Drives the + /// resize overlay's INSTANT feedback (blur + spinner) BEFORE the debounced request leaves; the + /// overlay clears when a decoded frame reaches this size (or on a timeout). Deduped per target. + public var onResizeTarget: ((_ width: UInt32, _ height: UInt32) -> Void)? /// `debounce` = quiet time after the last size event before requesting (Win32 gets /// `WM_EXITSIZEMOVE` for free; we debounce). `minSpacing` = floor between accepted requests @@ -80,6 +90,7 @@ public final class MatchWindowFollower { work?.cancel() work = nil pendingSize = nil + lastSteered = nil } } @@ -90,6 +101,24 @@ public final class MatchWindowFollower { guard enabled else { return } pendingSize = (widthPx, heightPx) schedule() + reportSteering(widthPx: widthPx, heightPx: heightPx) + } + + /// Report the resize overlay's START signal (deduped): the moment the normalized window size + /// differs from the live mode we're steering toward a new size. No connection / no negotiated + /// mode yet → nothing to compare against, skip. + private func reportSteering(widthPx: Int, heightPx: Int) { + guard let connection else { return } + let target = MatchWindow.normalize(widthPx: widthPx, heightPx: heightPx) + let mode = connection.currentMode() + guard mode.width > 0, mode.height > 0 else { return } + if target.width == mode.width, target.height == mode.height { + lastSteered = nil // back in sync — a later change re-reports + return + } + if lastSteered?.width == target.width, lastSteered?.height == target.height { return } + lastSteered = target + onResizeTarget?(target.width, target.height) } private func schedule() { diff --git a/clients/apple/Sources/PunktfunkKit/Video/ResizeIndicator.swift b/clients/apple/Sources/PunktfunkKit/Video/ResizeIndicator.swift new file mode 100644 index 00000000..985ceeae --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Video/ResizeIndicator.swift @@ -0,0 +1,63 @@ +// Resize-in-progress indicator state (design/midstream-resolution-resize.md — client UX). +// +// A mid-stream resize takes the host 0.3–2 s to rebuild its virtual display + encoder, and the +// first new-mode frame is an IDR that the decoder re-inits on. Rather than let the stream scale +// (stretch/blur) to the changing window during that gap, the client EMBRACES the delay: it shows a +// deliberate blur + spinner the instant a resize starts and clears it the instant the sharp +// new-resolution frame is on screen — so the wait reads as intentional, not as lag. +// +// This is driven ENTIRELY by signals the client already has (no new protocol): +// * START — the Match-window follower reports the size it is steering toward (instant, on the +// first resize layout, before the debounced request even leaves). +// * END — the decode pipeline reports each new-mode IDR's dimensions; when they reach the target +// the new picture is here. +// * TIMEOUT — the safety net for a switch that never delivers the exact target: the host rejected +// it (gamescope), capped it to an advertised mode, or a corrective ack landed a different size. +// +// Pure + side-effect-free so the transition logic is unit-tested without a live session or UI +// (`ResizeIndicatorTests`); `SessionModel` owns an instance and mirrors `active` into a @Published. + +import Foundation + +/// The pure state of the resize overlay. `now` is a monotonic time in seconds (the caller passes +/// `ProcessInfo.processInfo.systemUptime` or a test clock). +public struct ResizeIndicator { + /// Whether the blur + spinner should be shown. + public private(set) var active = false + /// The size the follower is steering toward — cleared once a decoded frame reaches it. + private var target: (width: UInt32, height: UInt32)? + /// When the current `active` span began — the timeout is measured from here. + private var since: TimeInterval? + /// How long to keep the overlay up if the target frame never arrives (rejected / capped switch). + public var timeout: TimeInterval + + public init(timeout: TimeInterval = 2.5) { self.timeout = timeout } + + /// The follower is steering toward `width`×`height` — a resize is under way. Show the overlay now + /// (instant feedback). Called only for a genuine change (the follower skips a target equal to the + /// live mode), possibly many times as a drag moves through sizes; the timeout re-arms whenever the + /// target actually changes so a slow drag never trips it mid-gesture. + public mutating func steering(width: UInt32, height: UInt32, now: TimeInterval) { + if !active || target?.width != width || target?.height != height { + since = now + } + target = (width, height) + active = true + } + + /// A decoded frame arrived at `width`×`height` (a new-mode IDR). Clears the overlay once it + /// matches the steered target — the sharp new-resolution picture is on glass. + public mutating func decoded(width: UInt32, height: UInt32) { + guard active, let t = target, t.width == width, t.height == height else { return } + active = false + since = nil + } + + /// Timeout safety net: stop showing the overlay once `timeout` has elapsed with no matching frame + /// (a rejected or host-capped switch never delivers the exact target). + public mutating func tick(now: TimeInterval) { + guard active, let s = since, now - s >= timeout else { return } + active = false + since = nil + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index 19bd8502..e11db891 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -85,7 +85,8 @@ final class SessionPresenter { displayMeter: LatencyMeter? = nil, makeDisplayLink: (AnyObject, Selector) -> CADisplayLink, onFrame: (@Sendable (AccessUnit) -> Void)?, - onSessionEnd: (@Sendable () -> Void)? + onSessionEnd: (@Sendable () -> Void)?, + onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil ) { stop() self.connection = connection @@ -128,12 +129,14 @@ final class SessionPresenter { link.add(to: .main, forMode: .common) stage2Link = link syncFrameRate(hz: connection.currentMode().refreshHz) - pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) + pipeline.start( + connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd, + onDecodedSize: onDecodedSize) } else { let pump = StreamPump() pump.start( connection: connection, layer: baseLayer, - onFrame: onFrame, onSessionEnd: onSessionEnd) + onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize) self.pump = pump } } diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index f21fcc02..68d20084 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -329,7 +329,8 @@ public final class Stage2Pipeline { public func start( connection: PunktfunkConnection, onFrame: (@Sendable (AccessUnit) -> Void)?, - onSessionEnd: (@Sendable () -> Void)? + onSessionEnd: (@Sendable () -> Void)?, + onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil ) { offsetNs = connection.clockOffsetNs recovery.bind(connection) // arm host-keyframe recovery for this session @@ -350,6 +351,9 @@ public final class Stage2Pipeline { let thread = Thread { defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset() var format: CMVideoFormatDescription? + // Report coded dims to the resize overlay only on a CHANGE (new-mode IDR), not per + // loss-recovery IDR at the same size (see StreamPump). + var lastDecodedDims: CMVideoDimensions? var lastFramesDropped = connection.framesDropped() // Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale): // keep asking until an IDR lands so a request swallowed by the throttle is re-sent. @@ -387,6 +391,11 @@ public final class Stage2Pipeline { onFrame?(au) if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) { format = f // refreshed on every IDR (mode changes included) + let dims = CMVideoFormatDescriptionGetDimensions(f) + if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height { + lastDecodedDims = dims + onDecodedSize?(Int(dims.width), Int(dims.height)) + } awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete } guard let f = format, !token.isStopped else { return true } diff --git a/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift index 8d193aa7..c19ebb7e 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift @@ -21,7 +21,8 @@ final class StreamPump { connection: PunktfunkConnection, layer: AVSampleBufferDisplayLayer, onFrame: (@Sendable (AccessUnit) -> Void)?, - onSessionEnd: (@Sendable () -> Void)? + onSessionEnd: (@Sendable () -> Void)?, + onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil ) { let token = token // Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery). @@ -35,6 +36,9 @@ final class StreamPump { let thread = Thread { var format: CMVideoFormatDescription? + // Report the coded dims to the resize overlay only when they CHANGE (a new-mode IDR), + // not on every loss-recovery IDR at the same size — so it fires once per real switch. + var lastDecodedDims: CMVideoDimensions? var lastFramesDropped = connection.framesDropped() // 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 @@ -79,6 +83,11 @@ final class StreamPump { let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data) if let f = idrFormat { format = f // refreshed on every IDR (mode changes included) + let dims = CMVideoFormatDescriptionGetDimensions(f) + if lastDecodedDims?.width != dims.width || lastDecodedDims?.height != dims.height { + lastDecodedDims = dims + onDecodedSize?(Int(dims.width), Int(dims.height)) + } if awaitingIDR { let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000) pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms") diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 0f267bc7..2d0f7db1 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -87,6 +87,8 @@ public struct StreamView: NSViewRepresentable { private let onDisconnectRequest: (() -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onSessionEnd: (@Sendable () -> Void)? + private let onResizeTarget: ((UInt32, UInt32) -> Void)? + private let onDecodedSize: (@Sendable (Int, Int) -> Void)? private let endToEndMeter: LatencyMeter? private let decodeMeter: LatencyMeter? private let displayMeter: LatencyMeter? @@ -108,6 +110,8 @@ public struct StreamView: NSViewRepresentable { onDisconnectRequest: (() -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil, + onResizeTarget: ((UInt32, UInt32) -> Void)? = nil, + onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil, endToEndMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil, displayMeter: LatencyMeter? = nil @@ -118,6 +122,8 @@ public struct StreamView: NSViewRepresentable { self.onDisconnectRequest = onDisconnectRequest self.onFrame = onFrame self.onSessionEnd = onSessionEnd + self.onResizeTarget = onResizeTarget + self.onDecodedSize = onDecodedSize self.endToEndMeter = endToEndMeter self.decodeMeter = decodeMeter self.displayMeter = displayMeter @@ -131,6 +137,8 @@ public struct StreamView: NSViewRepresentable { view.endToEndMeter = endToEndMeter view.decodeMeter = decodeMeter view.displayMeter = displayMeter + view.onResizeTarget = onResizeTarget + view.onDecodedSize = onDecodedSize view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) return view } @@ -142,6 +150,8 @@ public struct StreamView: NSViewRepresentable { view.endToEndMeter = endToEndMeter view.decodeMeter = decodeMeter view.displayMeter = displayMeter + view.onResizeTarget = onResizeTarget + view.onDecodedSize = onDecodedSize // SwiftUI reuses the NSView across state changes — repoint the pump only when the // connection identity actually changed. if view.connection !== connection { @@ -204,6 +214,13 @@ public final class StreamLayerView: NSView { /// view can't do that itself (the connection's owner disconnects). public var onDisconnectRequest: (() -> Void)? + /// Resize overlay signals (design/midstream-resolution-resize.md client UX): `onResizeTarget` + /// (main thread, via the follower) fires the instant the window starts steering toward a new + /// size; `onDecodedSize` (PUMP thread) fires when a new-mode IDR's dims land. The owner drives + /// the blur+spinner from these — set before `start()`. + public var onResizeTarget: ((UInt32, UInt32) -> Void)? + public var onDecodedSize: (@Sendable (Int, Int) -> Void)? + /// Main-thread only. False = input capture disabled outright (UI layered over the /// stream); flipping to true auto-engages once. public var captureEnabled = true { @@ -629,13 +646,16 @@ public final class StreamLayerView: NSView { displayMeter: displayMeter, makeDisplayLink: { displayLink(target: $0, selector: $1) }, onFrame: onFrame, - onSessionEnd: onSessionEnd) + 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. - matchFollower = MatchWindowFollower( + let follower = MatchWindowFollower( connection: connection, enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow)) + follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower) + matchFollower = follower layoutPresenter() requestAutoCapture() // entering a session is the deliberate "capture me" moment } diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index bb8eec6b..f79d002b 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -53,6 +53,8 @@ public struct StreamView: UIViewControllerRepresentable { private let onCaptureChange: ((Bool) -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onSessionEnd: (@Sendable () -> Void)? + private let onResizeTarget: ((UInt32, UInt32) -> Void)? + private let onDecodedSize: (@Sendable (Int, Int) -> Void)? private let endToEndMeter: LatencyMeter? private let decodeMeter: LatencyMeter? private let displayMeter: LatencyMeter? @@ -68,6 +70,8 @@ public struct StreamView: UIViewControllerRepresentable { onDisconnectRequest: (() -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil, + onResizeTarget: ((UInt32, UInt32) -> Void)? = nil, + onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil, endToEndMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil, displayMeter: LatencyMeter? = nil @@ -77,6 +81,8 @@ public struct StreamView: UIViewControllerRepresentable { self.onCaptureChange = onCaptureChange self.onFrame = onFrame self.onSessionEnd = onSessionEnd + self.onResizeTarget = onResizeTarget + self.onDecodedSize = onDecodedSize self.endToEndMeter = endToEndMeter self.decodeMeter = decodeMeter self.displayMeter = displayMeter @@ -89,6 +95,8 @@ public struct StreamView: UIViewControllerRepresentable { controller.endToEndMeter = endToEndMeter controller.decodeMeter = decodeMeter controller.displayMeter = displayMeter + controller.onResizeTarget = onResizeTarget + controller.onDecodedSize = onDecodedSize controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) return controller } @@ -99,6 +107,8 @@ public struct StreamView: UIViewControllerRepresentable { controller.endToEndMeter = endToEndMeter controller.decodeMeter = decodeMeter controller.displayMeter = displayMeter + controller.onResizeTarget = onResizeTarget + controller.onDecodedSize = onDecodedSize if controller.connection !== connection { controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) } @@ -166,6 +176,13 @@ public final class StreamViewController: StreamViewControllerBase { } var onCaptureChange: ((Bool) -> Void)? + /// Resize-overlay START: forwarded to the Match-window follower so a scene resize drives the + /// blur+spinner the instant the window differs from the live mode (iOS only — tvOS has no + /// follower). See `MatchWindowFollower.onResizeTarget`. + var onResizeTarget: ((UInt32, UInt32) -> Void)? + /// 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)? var captureEnabled = true { didSet { @@ -335,9 +352,11 @@ public final class StreamViewController: StreamViewControllerBase { // 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. - matchFollower = MatchWindowFollower( + let follower = MatchWindowFollower( connection: connection, enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow)) + follower.onResizeTarget = onResizeTarget + matchFollower = follower #endif // Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2 @@ -351,7 +370,8 @@ public final class StreamViewController: StreamViewControllerBase { displayMeter: displayMeter, makeDisplayLink: { CADisplayLink(target: $0, selector: $1) }, onFrame: onFrame, - onSessionEnd: onSessionEnd) + onSessionEnd: onSessionEnd, + onDecodedSize: onDecodedSize) layoutMetalLayer() #if os(iOS) diff --git a/clients/apple/Tests/PunktfunkKitTests/ResizeIndicatorTests.swift b/clients/apple/Tests/PunktfunkKitTests/ResizeIndicatorTests.swift new file mode 100644 index 00000000..98021e9f --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/ResizeIndicatorTests.swift @@ -0,0 +1,52 @@ +import XCTest + +@testable import PunktfunkKit + +final class ResizeIndicatorTests: XCTestCase { + func testInactiveUntilSteered() { + var r = ResizeIndicator() + XCTAssertFalse(r.active) + // A decoded frame with nothing pending is a no-op (session start / steady state). + r.decoded(width: 1920, height: 1080) + XCTAssertFalse(r.active) + } + + func testSteeringActivatesAndDecodedTargetClears() { + var r = ResizeIndicator() + r.steering(width: 2560, height: 1440, now: 0) + XCTAssertTrue(r.active) + // A frame at a DIFFERENT size (the old mode still draining) doesn't clear it. + r.decoded(width: 1920, height: 1080) + XCTAssertTrue(r.active) + // The target frame lands → clear. + r.decoded(width: 2560, height: 1440) + XCTAssertFalse(r.active) + } + + func testTimeoutClearsWhenTargetNeverArrives() { + var r = ResizeIndicator(timeout: 2.5) + r.steering(width: 2560, height: 1440, now: 10) + r.tick(now: 12) // 2 s < timeout — still up + XCTAssertTrue(r.active) + r.tick(now: 12.6) // 2.6 s ≥ timeout — a rejected/capped switch clears + XCTAssertFalse(r.active) + } + + func testDragReArmsTimeoutOnEachNewTarget() { + var r = ResizeIndicator(timeout: 2.5) + r.steering(width: 2000, height: 1200, now: 0) + r.steering(width: 2200, height: 1200, now: 2) // target changed → since re-armed to 2 + r.tick(now: 4) // only 2 s since the last change — still up (drag isn't a timeout) + XCTAssertTrue(r.active) + r.tick(now: 4.6) // 2.6 s since the last change → clears + XCTAssertFalse(r.active) + } + + func testSteadyDragDoesNotResetTimeout() { + var r = ResizeIndicator(timeout: 2.5) + r.steering(width: 2560, height: 1440, now: 0) + r.steering(width: 2560, height: 1440, now: 1) // SAME target → since stays 0 + r.tick(now: 2.6) // 2.6 s since the ORIGINAL steer → clears (not reset by the repeat) + XCTAssertFalse(r.active) + } +}