feat(apple): default-UI connect/wake modal, auto-wake toggle, pixel-exact windowed streaming
Three client-UX changes that share the connect/present path (and settings files): - Connect/wake overlay is now mode-aware: the console/gamepad UI keeps the full-screen aurora takeover, while the default (touch/desktop) UI shows a Liquid Glass modal over the host grid — the takeover looked out of place there. - Add an auto-wake toggle (DefaultsKey.autoWake, default on) across macOS/iOS/tvOS Settings + the gamepad settings view; gate startSession/prepareWake and the gamepad "Wake & Connect" label on it. MAC-address learning stays always-on. - Windowed sessions now stream at the window's native pixels (Match-window default-on) so the picture is 1:1 pixel-exact instead of the presenter resampling a fixed-mode frame; fullscreen reports full-display px, also 1:1. Also lands the mid-resize aspect-fit tracking (decoded contentSize) that keeps the picture undistorted after a resize. swift build + swift test (121 tests) green; screenshot scenes verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -176,8 +176,14 @@ public final class StreamLayerView: NSView {
|
||||
private let presenter = SessionPresenter()
|
||||
public private(set) var connection: PunktfunkConnection?
|
||||
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||
/// setting is on; fed the view's physical-pixel size on every relayout.
|
||||
/// setting is on (DEFAULT on, for pixel-exact windowed streaming); fed the view's physical-pixel
|
||||
/// size on every relayout so the host mode tracks the window (1:1, no presenter resample).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
/// Last decoded frame size fed into the presenter's aspect-fit. A new-mode IDR after a resize
|
||||
/// re-fits the metal sublayer to the REAL content aspect here — `layout()` only re-runs on a
|
||||
/// bounds change and a resize-END has none, so without this the layer keeps its pre-resize aspect
|
||||
/// and the shader stretches the new frame into it (black bars + squish). Main-thread only.
|
||||
private var lastDecodedContentSize: CGSize?
|
||||
private let cursorCapture = CursorCapture()
|
||||
private var inputCapture: InputCapture?
|
||||
private var appObservers: [NSObjectProtocol] = []
|
||||
@@ -638,6 +644,10 @@ public final class StreamLayerView: NSView {
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from
|
||||
// NSView.displayLink so it tracks the display this view is on.
|
||||
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||
let overlayDecodedSize = onDecodedSize
|
||||
presenter.start(
|
||||
connection: connection,
|
||||
baseLayer: displayLayer,
|
||||
@@ -647,13 +657,19 @@ public final class StreamLayerView: NSView {
|
||||
makeDisplayLink: { displayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: onDecodedSize) // resize overlay END signal (new-mode IDR dims)
|
||||
// Match-window (C3): follow the window's pixel size when the setting is on. Latched at
|
||||
// session start (mirrors the other clients); the first real `layout()` feeds the initial
|
||||
// size, so the stream converges to the window even if the connect used the explicit mode.
|
||||
onDecodedSize: { [weak self] w, h in // resize overlay END signal (new-mode IDR dims)
|
||||
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||
overlayDecodedSize?(w, h)
|
||||
})
|
||||
// Match-window (C3): follow the window's pixel size — DEFAULT ON, so a windowed session
|
||||
// streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into a
|
||||
// non-matching window. The first real `layout()` feeds the initial size, so the stream
|
||||
// converges to the window even though the connect used the explicit/display mode; entering
|
||||
// fullscreen reports the full-display px, restoring a native-res 1:1 present there too.
|
||||
// `?? true` so an unset default matches the Settings toggle (which also defaults on).
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||
follower.onResizeTarget = onResizeTarget // resize overlay START signal (instant, on the follower)
|
||||
matchFollower = follower
|
||||
layoutPresenter()
|
||||
@@ -679,6 +695,18 @@ public final class StreamLayerView: NSView {
|
||||
layoutPresenter() // backing scale changed (e.g. moved to a non-retina display)
|
||||
}
|
||||
|
||||
/// A new decoded size landed (a new-mode IDR after a resize, or the session's first frame): push
|
||||
/// it to the presenter's aspect-fit and re-layout NOW. A resize-END triggers no `layout()`, so
|
||||
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||
let size = CGSize(width: width, height: height)
|
||||
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||
lastDecodedContentSize = size
|
||||
presenter.setContentSize(size)
|
||||
layoutPresenter()
|
||||
}
|
||||
|
||||
/// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with
|
||||
/// whoever owns it (PunktfunkConnection.close() is safe alongside a draining pump).
|
||||
public func stop() {
|
||||
@@ -688,6 +716,7 @@ public final class StreamLayerView: NSView {
|
||||
inputCapture = nil
|
||||
presenter.stop()
|
||||
matchFollower = nil
|
||||
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||
connection = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -158,9 +158,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// mouse/keyboard stay released after navigating out and nothing re-grabs them.
|
||||
private var wasCapturedOnResign = false
|
||||
/// Match-window resize follower (C3) — non-nil while a session is active AND the `matchWindow`
|
||||
/// setting is on; fed the view's physical-pixel size from `viewDidLayoutSubviews` so an iPad
|
||||
/// Stage Manager / Split View scene resize renegotiates the host mode. iOS only (iPhone
|
||||
/// naturally no-ops fullscreen; tvOS drives display modes via AVDisplayManager instead).
|
||||
/// setting is on (DEFAULT on, for pixel-exact scene streaming); fed the view's physical-pixel
|
||||
/// size from `viewDidLayoutSubviews` so an iPad Stage Manager / Split View scene resize
|
||||
/// renegotiates the host mode (1:1, no presenter resample). iOS only (iPhone naturally no-ops
|
||||
/// its fixed full-screen scene; tvOS drives display modes via AVDisplayManager instead).
|
||||
private var matchFollower: MatchWindowFollower?
|
||||
#endif
|
||||
|
||||
@@ -183,6 +184,11 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
/// Resize-overlay END: the presenter reports the coded dims of each new-mode IDR here, so the
|
||||
/// overlay clears when a frame at the requested size actually decodes.
|
||||
var onDecodedSize: (@Sendable (Int, Int) -> Void)?
|
||||
/// Last decoded size fed into the presenter's aspect-fit. A new-mode IDR (an iPad scene resize,
|
||||
/// or a tvOS AVDisplayManager mode switch) re-fits the metal sublayer to the REAL content aspect
|
||||
/// here — `viewDidLayoutSubviews` only re-runs on a bounds change, which a resize-END lacks, so
|
||||
/// without this the layer keeps its pre-resize aspect and stretches the new frame into it. Main.
|
||||
private var lastDecodedContentSize: CGSize?
|
||||
|
||||
var captureEnabled = true {
|
||||
didSet {
|
||||
@@ -349,12 +355,14 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
// Match-window (C3): follow the scene's pixel size when the setting is on. Latched at
|
||||
// session start (mirrors the other clients); `viewDidLayoutSubviews` feeds it — covers
|
||||
// Stage Manager / Split View resizes and rotation. iPhone fullscreen naturally no-ops.
|
||||
// Match-window (C3): follow the scene's pixel size — DEFAULT ON, so a resizable iPad scene
|
||||
// streams 1:1 (pixel-exact) instead of the presenter resampling a fixed-mode frame into it.
|
||||
// `viewDidLayoutSubviews` feeds it — covers Stage Manager / Split View resizes and rotation.
|
||||
// iPhone is a fixed full-screen scene, so this naturally no-ops (reports the device mode).
|
||||
// `?? true` so an unset default matches the Settings toggle (which also defaults on).
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.bool(forKey: DefaultsKey.matchWindow))
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||
follower.onResizeTarget = onResizeTarget
|
||||
matchFollower = follower
|
||||
#endif
|
||||
@@ -362,6 +370,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
// Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2
|
||||
// (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by
|
||||
// default, the stage-1 pump as the Metal-missing / DEBUG fallback.
|
||||
// Intercept the pump's coded-dims callback: re-fit the metal sublayer to the real content
|
||||
// aspect (main thread) BEFORE forwarding to the owner's overlay END-signal. Fires only on a
|
||||
// size CHANGE (first frame + each resolved resize), so this is rare, not per-frame.
|
||||
let overlayDecodedSize = onDecodedSize
|
||||
presenter.start(
|
||||
connection: connection,
|
||||
baseLayer: streamView.displayLayer,
|
||||
@@ -371,7 +383,10 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
makeDisplayLink: { CADisplayLink(target: $0, selector: $1) },
|
||||
onFrame: onFrame,
|
||||
onSessionEnd: onSessionEnd,
|
||||
onDecodedSize: onDecodedSize)
|
||||
onDecodedSize: { [weak self] w, h in
|
||||
DispatchQueue.main.async { self?.noteDecodedContentSize(width: w, height: h) }
|
||||
overlayDecodedSize?(w, h)
|
||||
})
|
||||
layoutMetalLayer()
|
||||
|
||||
#if os(iOS)
|
||||
@@ -451,6 +466,7 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
sessionDisplayManager = nil
|
||||
#endif
|
||||
presenter.stop()
|
||||
lastDecodedContentSize = nil // the next session re-derives it from its first frame
|
||||
connection = nil
|
||||
}
|
||||
|
||||
@@ -527,6 +543,18 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
presenter.layout(in: streamView.bounds, contentsScale: renderScale)
|
||||
}
|
||||
|
||||
/// A new decoded size landed (a scene/mode resize's new IDR, or the first frame): push it to the
|
||||
/// presenter's aspect-fit and re-layout NOW. A resize-END triggers no `viewDidLayoutSubviews`, so
|
||||
/// this is what makes the metal sublayer track the new content aspect instead of stretching the
|
||||
/// new frame into the pre-resize box. Deduped so a same-size repeat is a no-op. Main thread.
|
||||
private func noteDecodedContentSize(width: Int, height: Int) {
|
||||
let size = CGSize(width: width, height: height)
|
||||
guard size.width > 0, size.height > 0, size != lastDecodedContentSize else { return }
|
||||
lastDecodedContentSize = size
|
||||
presenter.setContentSize(size)
|
||||
layoutMetalLayer()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func setCaptured(_ on: Bool) {
|
||||
if on {
|
||||
|
||||
Reference in New Issue
Block a user