feat(resize/apple): resize overlay — blur + spinner during mid-stream resize

Make a Match-window resize deliberate instead of a stutter: blur the live
stream and show a spinner while the host rebuilds its virtual display +
encoder and VideoToolbox re-inits on the new-mode IDR. No new protocol —
driven entirely by existing client signals.

- ResizeIndicator (pure core, unit-tested): START = follower steering,
  END = a decoded frame at the target size, TIMEOUT = 2.5s safety net for a
  rejected/capped switch that never yields a new-size frame; re-arms only on
  a CHANGED target, not a repeated same-size drag.
- MatchWindowFollower.onResizeTarget fires the instant the window differs
  from the live mode (deduped via lastSteered); a new onDecodedSize callback
  threads each new-mode IDR's coded dims through StreamPump/Stage2Pipeline →
  SessionPresenter → both stream views.
- SessionModel gains @Published resizing (+ resizeTargeted/resizeDecoded, a
  tick on the 1 Hz stats timer, reset on disconnect); ContentView blurs the
  stream 16px and overlays ResizeIndicatorView while resizing (the 32px
  trust-prompt blur is unchanged and takes precedence).

tvOS declares the props but never fires the follower (it drives modes via
AVDisplayManager), so the overlay stays dormant there. Pure core verified on
the Linux toolchain; full AppKit/UIKit build pending on a Mac.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 15:58:14 +02:00
parent d294b3923c
commit f01c5e210c
11 changed files with 313 additions and 10 deletions
@@ -327,12 +327,25 @@ struct ContentView: View {
}() }()
return ZStack { return ZStack {
stream(captureEnabled: pendingFingerprint == nil) 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 { .overlay {
if pendingFingerprint != nil { if pendingFingerprint != nil {
Color.black.opacity(0.45) 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 { if let fp = pendingFingerprint {
TrustCardView( TrustCardView(
fingerprint: fp, fingerprint: fp,
@@ -410,6 +423,16 @@ struct ContentView: View {
onSessionEnd: { [weak model] in onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() } 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, endToEndMeter: model.endToEnd,
decodeMeter: model.decodeStage, decodeMeter: model.decodeStage,
displayMeter: model.displayStage displayMeter: model.displayStage
@@ -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
}
}
@@ -109,6 +109,16 @@ final class SessionModel: ObservableObject {
/// Mirrors StreamView's capture state (it owns the input capture; this drives the /// Mirrors StreamView's capture state (it owns the input capture; this drives the
/// HUD's "click to capture" / " releases" hint). /// HUD's "click to capture" / " releases" hint).
@Published var mouseCaptured = false @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() let meter = FrameMeter()
/// Capturereceived (the host+network stage), fed per AU at receipt by the stream view's /// Capturereceived (the host+network stage), fed per AU at receipt by the stream view's
@@ -364,6 +374,8 @@ final class SessionModel: ObservableObject {
lostFrames = 0 lostFrames = 0
lostPct = 0 lostPct = 0
mouseCaptured = false 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. /// 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)." 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() { private func beginStreaming() {
guard let conn = connection else { return } guard let conn = connection else { return }
// Input capture itself is owned by StreamView (engaged by the captureEnabled // 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 let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self else { return } guard let self else { return }
Task { @MainActor in 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() let (frames, bytes, total) = self.meter.drain()
self.fps = frames self.fps = frames
self.mbps = Double(bytes) * 8 / 1_000_000 self.mbps = Double(bytes) * 8 / 1_000_000
@@ -57,6 +57,16 @@ public final class MatchWindowFollower {
private var pendingSize: (width: Int, height: Int)? private var pendingSize: (width: Int, height: Int)?
private var lastRequested: (width: UInt32, height: UInt32)? private var lastRequested: (width: UInt32, height: UInt32)?
private var lastRequestAt: Date? 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 /// `debounce` = quiet time after the last size event before requesting (Win32 gets
/// `WM_EXITSIZEMOVE` for free; we debounce). `minSpacing` = floor between accepted requests /// `WM_EXITSIZEMOVE` for free; we debounce). `minSpacing` = floor between accepted requests
@@ -80,6 +90,7 @@ public final class MatchWindowFollower {
work?.cancel() work?.cancel()
work = nil work = nil
pendingSize = nil pendingSize = nil
lastSteered = nil
} }
} }
@@ -90,6 +101,24 @@ public final class MatchWindowFollower {
guard enabled else { return } guard enabled else { return }
pendingSize = (widthPx, heightPx) pendingSize = (widthPx, heightPx)
schedule() 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() { private func schedule() {
@@ -0,0 +1,63 @@
// Resize-in-progress indicator state (design/midstream-resolution-resize.md client UX).
//
// A mid-stream resize takes the host 0.32 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
}
}
@@ -85,7 +85,8 @@ final class SessionPresenter {
displayMeter: LatencyMeter? = nil, displayMeter: LatencyMeter? = nil,
makeDisplayLink: (AnyObject, Selector) -> CADisplayLink, makeDisplayLink: (AnyObject, Selector) -> CADisplayLink,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)? onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) { ) {
stop() stop()
self.connection = connection self.connection = connection
@@ -128,12 +129,14 @@ final class SessionPresenter {
link.add(to: .main, forMode: .common) link.add(to: .main, forMode: .common)
stage2Link = link stage2Link = link
syncFrameRate(hz: connection.currentMode().refreshHz) syncFrameRate(hz: connection.currentMode().refreshHz)
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) pipeline.start(
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
onDecodedSize: onDecodedSize)
} else { } else {
let pump = StreamPump() let pump = StreamPump()
pump.start( pump.start(
connection: connection, layer: baseLayer, connection: connection, layer: baseLayer,
onFrame: onFrame, onSessionEnd: onSessionEnd) onFrame: onFrame, onSessionEnd: onSessionEnd, onDecodedSize: onDecodedSize)
self.pump = pump self.pump = pump
} }
} }
@@ -329,7 +329,8 @@ public final class Stage2Pipeline {
public func start( public func start(
connection: PunktfunkConnection, connection: PunktfunkConnection,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)? onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) { ) {
offsetNs = connection.clockOffsetNs offsetNs = connection.clockOffsetNs
recovery.bind(connection) // arm host-keyframe recovery for this session recovery.bind(connection) // arm host-keyframe recovery for this session
@@ -350,6 +351,9 @@ public final class Stage2Pipeline {
let thread = Thread { let thread = Thread {
defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset() defer { pumpStopped.signal() } // let stop() join the pump (bounded) before decoder.reset()
var format: CMVideoFormatDescription? 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() var lastFramesDropped = connection.framesDropped()
// Persistent recovery WANT, not a one-shot edge (see StreamPump for the full rationale): // 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. // 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) onFrame?(au)
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) { if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
format = f // refreshed on every IDR (mode changes included) 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 awaitingIDR = false // a fresh IDR re-anchored decode recovery complete
} }
guard let f = format, !token.isStopped else { return true } guard let f = format, !token.isStopped else { return true }
@@ -21,7 +21,8 @@ final class StreamPump {
connection: PunktfunkConnection, connection: PunktfunkConnection,
layer: AVSampleBufferDisplayLayer, layer: AVSampleBufferDisplayLayer,
onFrame: (@Sendable (AccessUnit) -> Void)?, onFrame: (@Sendable (AccessUnit) -> Void)?,
onSessionEnd: (@Sendable () -> Void)? onSessionEnd: (@Sendable () -> Void)?,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil
) { ) {
let token = token let token = token
// Coalesced host keyframe requests (100 ms throttle see KeyframeRecovery). // Coalesced host keyframe requests (100 ms throttle see KeyframeRecovery).
@@ -35,6 +36,9 @@ final class StreamPump {
let thread = Thread { let thread = Thread {
var format: CMVideoFormatDescription? 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() var lastFramesDropped = connection.framesDropped()
// Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a // 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 // 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) let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
if let f = idrFormat { if let f = idrFormat {
format = f // refreshed on every IDR (mode changes included) 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 { if awaitingIDR {
let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000) let ms = Int(Date().timeIntervalSince(awaitingSince) * 1000)
pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms") pumpLog.notice("video: recovery IDR received — resumed after \(ms, privacy: .public) ms")
@@ -87,6 +87,8 @@ public struct StreamView: NSViewRepresentable {
private let onDisconnectRequest: (() -> Void)? private let onDisconnectRequest: (() -> Void)?
private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> 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 endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter? private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter? private let displayMeter: LatencyMeter?
@@ -108,6 +110,8 @@ public struct StreamView: NSViewRepresentable {
onDisconnectRequest: (() -> Void)? = nil, onDisconnectRequest: (() -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil,
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil, endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil displayMeter: LatencyMeter? = nil
@@ -118,6 +122,8 @@ public struct StreamView: NSViewRepresentable {
self.onDisconnectRequest = onDisconnectRequest self.onDisconnectRequest = onDisconnectRequest
self.onFrame = onFrame self.onFrame = onFrame
self.onSessionEnd = onSessionEnd self.onSessionEnd = onSessionEnd
self.onResizeTarget = onResizeTarget
self.onDecodedSize = onDecodedSize
self.endToEndMeter = endToEndMeter self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter self.decodeMeter = decodeMeter
self.displayMeter = displayMeter self.displayMeter = displayMeter
@@ -131,6 +137,8 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter view.decodeMeter = decodeMeter
view.displayMeter = displayMeter view.displayMeter = displayMeter
view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize
view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
return view return view
} }
@@ -142,6 +150,8 @@ public struct StreamView: NSViewRepresentable {
view.endToEndMeter = endToEndMeter view.endToEndMeter = endToEndMeter
view.decodeMeter = decodeMeter view.decodeMeter = decodeMeter
view.displayMeter = displayMeter view.displayMeter = displayMeter
view.onResizeTarget = onResizeTarget
view.onDecodedSize = onDecodedSize
// SwiftUI reuses the NSView across state changes repoint the pump only when the // SwiftUI reuses the NSView across state changes repoint the pump only when the
// connection identity actually changed. // connection identity actually changed.
if view.connection !== connection { if view.connection !== connection {
@@ -204,6 +214,13 @@ public final class StreamLayerView: NSView {
/// view can't do that itself (the connection's owner disconnects). /// view can't do that itself (the connection's owner disconnects).
public var onDisconnectRequest: (() -> Void)? 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 /// Main-thread only. False = input capture disabled outright (UI layered over the
/// stream); flipping to true auto-engages once. /// stream); flipping to true auto-engages once.
public var captureEnabled = true { public var captureEnabled = true {
@@ -629,13 +646,16 @@ public final class StreamLayerView: NSView {
displayMeter: displayMeter, displayMeter: displayMeter,
makeDisplayLink: { displayLink(target: $0, selector: $1) }, makeDisplayLink: { displayLink(target: $0, selector: $1) },
onFrame: onFrame, 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 // 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 // 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. // size, so the stream converges to the window even if the connect used the explicit mode.
matchFollower = MatchWindowFollower( let follower = MatchWindowFollower(
connection: connection, connection: connection,
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow)) enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
matchFollower = follower
layoutPresenter() layoutPresenter()
requestAutoCapture() // entering a session is the deliberate "capture me" moment requestAutoCapture() // entering a session is the deliberate "capture me" moment
} }
@@ -53,6 +53,8 @@ public struct StreamView: UIViewControllerRepresentable {
private let onCaptureChange: ((Bool) -> Void)? private let onCaptureChange: ((Bool) -> Void)?
private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onFrame: (@Sendable (AccessUnit) -> Void)?
private let onSessionEnd: (@Sendable () -> 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 endToEndMeter: LatencyMeter?
private let decodeMeter: LatencyMeter? private let decodeMeter: LatencyMeter?
private let displayMeter: LatencyMeter? private let displayMeter: LatencyMeter?
@@ -68,6 +70,8 @@ public struct StreamView: UIViewControllerRepresentable {
onDisconnectRequest: (() -> Void)? = nil, onDisconnectRequest: (() -> Void)? = nil,
onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil,
onSessionEnd: (@Sendable () -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil,
onResizeTarget: ((UInt32, UInt32) -> Void)? = nil,
onDecodedSize: (@Sendable (Int, Int) -> Void)? = nil,
endToEndMeter: LatencyMeter? = nil, endToEndMeter: LatencyMeter? = nil,
decodeMeter: LatencyMeter? = nil, decodeMeter: LatencyMeter? = nil,
displayMeter: LatencyMeter? = nil displayMeter: LatencyMeter? = nil
@@ -77,6 +81,8 @@ public struct StreamView: UIViewControllerRepresentable {
self.onCaptureChange = onCaptureChange self.onCaptureChange = onCaptureChange
self.onFrame = onFrame self.onFrame = onFrame
self.onSessionEnd = onSessionEnd self.onSessionEnd = onSessionEnd
self.onResizeTarget = onResizeTarget
self.onDecodedSize = onDecodedSize
self.endToEndMeter = endToEndMeter self.endToEndMeter = endToEndMeter
self.decodeMeter = decodeMeter self.decodeMeter = decodeMeter
self.displayMeter = displayMeter self.displayMeter = displayMeter
@@ -89,6 +95,8 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter controller.displayMeter = displayMeter
controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
return controller return controller
} }
@@ -99,6 +107,8 @@ public struct StreamView: UIViewControllerRepresentable {
controller.endToEndMeter = endToEndMeter controller.endToEndMeter = endToEndMeter
controller.decodeMeter = decodeMeter controller.decodeMeter = decodeMeter
controller.displayMeter = displayMeter controller.displayMeter = displayMeter
controller.onResizeTarget = onResizeTarget
controller.onDecodedSize = onDecodedSize
if controller.connection !== connection { if controller.connection !== connection {
controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
} }
@@ -166,6 +176,13 @@ public final class StreamViewController: StreamViewControllerBase {
} }
var onCaptureChange: ((Bool) -> Void)? 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 { var captureEnabled = true {
didSet { 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 // 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 // session start (mirrors the other clients); `viewDidLayoutSubviews` feeds it covers
// Stage Manager / Split View resizes and rotation. iPhone fullscreen naturally no-ops. // Stage Manager / Split View resizes and rotation. iPhone fullscreen naturally no-ops.
matchFollower = MatchWindowFollower( let follower = MatchWindowFollower(
connection: connection, connection: connection,
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow)) enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
follower.onResizeTarget = onResizeTarget
matchFollower = follower
#endif #endif
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2 // Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
@@ -351,7 +370,8 @@ public final class StreamViewController: StreamViewControllerBase {
displayMeter: displayMeter, displayMeter: displayMeter,
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) }, makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
onFrame: onFrame, onFrame: onFrame,
onSessionEnd: onSessionEnd) onSessionEnd: onSessionEnd,
onDecodedSize: onDecodedSize)
layoutMetalLayer() layoutMetalLayer()
#if os(iOS) #if os(iOS)
@@ -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)
}
}