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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
/// 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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user