feat(apple): iPad ⌃⌥⇧Q release chord + click-to-recapture, pixel-grid snap, match-window opt-in
ci / docs-site (push) Successful in 49s
ci / rust (push) Failing after 51s
ci / web (push) Successful in 52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish (push) Failing after 4m20s
apple / swift (push) Successful in 4m43s
arch / build-publish (push) Failing after 4m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 4m8s
ci / bench (push) Successful in 5m43s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m21s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m33s
windows-host / package (push) Successful in 9m2s
flatpak / build-publish (push) Failing after 8m11s
android / android (push) Successful in 12m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m39s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m3s
release / apple (push) Successful in 21m56s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m31s
apple / screenshots (push) Successful in 19m11s
ci / docs-site (push) Successful in 49s
ci / rust (push) Failing after 51s
ci / web (push) Successful in 52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish (push) Failing after 4m20s
apple / swift (push) Successful in 4m43s
arch / build-publish (push) Failing after 4m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 4m8s
ci / bench (push) Successful in 5m43s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m21s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 3m33s
windows-host / package (push) Successful in 9m2s
flatpak / build-publish (push) Failing after 8m11s
android / android (push) Successful in 12m4s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m39s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m3s
release / apple (push) Successful in 21m56s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m31s
apple / screenshots (push) Successful in 19m11s
- InputCapture / StreamViewIOS: iPad ⌃⌥⇧Q un-capture chord, recognized from the GCKeyboard HID stream (no NSEvent monitor on iOS) for cross-client parity with the macOS/Windows/Linux combo; and a click into the video re-engages capture — the iPad analogue of macOS mouseDown → engageCapture(fromClick:), with the engaging click suppressed toward the host. - SessionPresenter: snap the aspect-fit sublayer frame to the backing pixel grid. AVMakeRect centers the fit rect at fractional points, so the compositor resampled the layer — a uniform "everything soft" blur even when the drawable was pixel-exact 1:1. Rounding origin + size to device pixels makes the composite a true 1:1 blit; idempotent when already aligned. - MetalVideoPresenter: PUNKTFUNK_BILINEAR_LUMA=1 A/B lever — compiles the shader with Catmull-Rom luma off (plain bilinear) to isolate bicubic overshoot from upstream fringing. - SettingsView / StreamView / StreamViewIOS: match-window reverted to opt-in (default OFF) — the explicit mode is used and never auto-resized unless enabled.
This commit is contained in:
@@ -24,7 +24,9 @@
|
||||
// (== locked): GCMouse forwards only WHILE locked, the UIKit indirect path (motion, buttons AND
|
||||
// scroll) only while NOT locked — so a pointer that emits both channels under lock can't double-send.
|
||||
// Hardware keyboard forwarding shares InputCapture with macOS — auto-engaged when streaming
|
||||
// starts, ⌘⎋ toggles (detected from the HID stream; there is no NSEvent monitor here).
|
||||
// starts, ⌘⎋ toggles and ⌃⌥⇧Q releases (both detected from the HID stream; there is no NSEvent
|
||||
// monitor here). ⌃⌥⇧Q is the cross-client Ctrl+Alt+Shift+Q — it un-captures so the Magic Keyboard
|
||||
// trackpad drives the local iPad UI again.
|
||||
//
|
||||
// The public type is named StreamView like its macOS twin (each is platform-gated), so
|
||||
// the SwiftUI app layer is identical on both platforms.
|
||||
@@ -337,7 +339,19 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
x: p.x, y: p.y, surfaceWidth: p.w, surfaceHeight: p.h)
|
||||
}
|
||||
streamView.onPointerButton = { [weak self] button, down in
|
||||
guard let self, self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
guard let self else { return }
|
||||
// Released → a trackpad/mouse click into the video RE-ENGAGES capture (the iPad
|
||||
// analogue of macOS's `mouseDown → engageCapture(fromClick:)`, and the click-mirror of
|
||||
// the ⌘⎋ / ⌃⌥⇧Q keyboard toggles). Only the button-DOWN engages; that click is the local
|
||||
// engage gesture, so it's suppressed toward the host (`fromClick`) and never forwarded —
|
||||
// its release is swallowed by InputCapture's suppress latch, whichever path delivers it.
|
||||
// (Finger taps are untouched: touch always plays directly, so only the indirect pointer
|
||||
// re-captures.) Captured already → the absolute path forwards the button as before.
|
||||
if !self.captured {
|
||||
if down, self.captureEnabled { self.setCaptured(true, fromClick: true) }
|
||||
return
|
||||
}
|
||||
guard self.inputCapture?.gcMouseForwarding == false else { return }
|
||||
self.inputCapture?.sendMouseButton(button, pressed: down)
|
||||
}
|
||||
streamView.onScroll = { [weak self] dx, dy in
|
||||
@@ -350,19 +364,27 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
guard let self else { return }
|
||||
self.setCaptured(!self.captured)
|
||||
}
|
||||
// ⌃⌥⇧Q (cross-client parity with macOS/Windows/Linux) releases the captured pointer +
|
||||
// keyboard so the Magic Keyboard trackpad returns to driving the local iPad UI. Detected
|
||||
// from the HID stream in InputCapture (no NSEvent monitor on iOS); unlike the ⌘⎋ toggle it
|
||||
// only ever RELEASES — re-pressing it while already released is a no-op (setCaptured guards).
|
||||
capture.onReleaseCapture = { [weak self] in
|
||||
self?.setCaptured(false)
|
||||
}
|
||||
capture.onPreempted = { [weak self] in
|
||||
self?.setCaptured(false)
|
||||
}
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
// Match-window (C3): follow the scene's pixel size — DEFAULT ON, so a resizable iPad scene
|
||||
// Match-window (C3): when ON, follow the scene's pixel size 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).
|
||||
// OPT-IN — `?? false` matches the Settings toggle (which also defaults off); an unset
|
||||
// default keeps the explicit mode.
|
||||
let follower = MatchWindowFollower(
|
||||
connection: connection,
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? true)
|
||||
enabled: UserDefaults.standard.object(forKey: DefaultsKey.matchWindow) as? Bool ?? false)
|
||||
follower.onResizeTarget = onResizeTarget
|
||||
matchFollower = follower
|
||||
#endif
|
||||
@@ -422,6 +444,19 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
) { [weak self] _ in
|
||||
self?.syncPointerLock()
|
||||
})
|
||||
// The Stream menu's "Release Mouse" (⌃⌥⇧Q) posts this — the discoverable menu surface for
|
||||
// the RELEASED state. While CAPTURED the combo is recognized from the HID stream in
|
||||
// InputCapture (onReleaseCapture) before the menu sees it, so in practice this fires as a
|
||||
// not-captured no-op (setCaptured guards it); wired for honesty + a non-GC fallback. Only the
|
||||
// foreground-active scene's stream acts — the iPad analogue of macOS's key-window guard, so a
|
||||
// second Stage Manager scene isn't released out from under the user.
|
||||
observers.append(NotificationCenter.default.addObserver(
|
||||
forName: .punktfunkReleaseCapture, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self,
|
||||
self.view.window?.windowScene?.activationState == .foregroundActive else { return }
|
||||
self.setCaptured(false)
|
||||
})
|
||||
|
||||
if captureEnabled {
|
||||
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
||||
@@ -556,11 +591,15 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func setCaptured(_ on: Bool) {
|
||||
/// `fromClick` marks a click-driven engage (the released-state pointer click that re-captures):
|
||||
/// that click's press/release are suppressed toward the host — it's the local engage gesture,
|
||||
/// not a host click — exactly as macOS's `engageCapture(fromClick:)` does. Keyboard-driven
|
||||
/// engages (⌘⎋) pass false so a normal click still reaches the host.
|
||||
private func setCaptured(_ on: Bool, fromClick: Bool = false) {
|
||||
if on {
|
||||
// `connection != nil` is the session-active gate (presenter internals are opaque here).
|
||||
guard captureEnabled, !captured, connection != nil else { return }
|
||||
inputCapture?.setForwarding(true)
|
||||
inputCapture?.setForwarding(true, suppressClick: fromClick)
|
||||
captured = true
|
||||
} else {
|
||||
guard captured else { return }
|
||||
|
||||
Reference in New Issue
Block a user