fix(apple/iOS): immersive streaming — edge-to-edge, no status bar, hidden cursor, native default mode
ci / rust (push) Has been cancelled

Streaming on iPad left the status bar up and the video boxed inside the safe areas, on
top of a 16:9 default mode letterboxing on the 4:3 screen, with the iPadOS cursor
hovering over the video. The session view is now immersive on iOS:

- .ignoresSafeArea + .statusBarHidden + .persistentSystemOverlays(.hidden) for the
  session only (home gets its chrome back on disconnect).
- First run seeds the stream mode from the device's native screen
  (UIScreen.nativeBounds + maximumFramesPerSecond) instead of 1920×1080 — verified
  live: a fresh install negotiated the iPad's 2752×2064 with the host. macOS keeps the
  1080p default (a desktop window is not the screen).
- The iPadOS cursor hides while over the video (UIPointerInteraction .hidden(),
  re-resolved on capture toggles) — the host renders its own cursor from our deltas;
  true pointer lock through UIHostingController remains the documented gap.

Found along the way (host-side, not fixed here): at very high modes a keyframe burst
can fill the UDP send buffer and m3 treats the sendmmsg WouldBlock as fatal
("session ended with error: submit_frame: WouldBlock") instead of backpressuring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:44:29 +02:00
parent 57f7e32c24
commit 154da2dc58
3 changed files with 51 additions and 8 deletions
@@ -66,12 +66,13 @@ public struct StreamView: UIViewControllerRepresentable {
}
}
public final class StreamViewController: UIViewController {
public final class StreamViewController: UIViewController, UIPointerInteractionDelegate {
public private(set) var connection: PunktfunkConnection?
private var pump: StreamPump?
private var inputCapture: InputCapture?
private var captured = false
private var observers: [NSObjectProtocol] = []
private var pointerInteraction: UIPointerInteraction?
var onCaptureChange: ((Bool) -> Void)?
@@ -89,6 +90,19 @@ public final class StreamViewController: UIViewController {
public override func loadView() {
view = StreamLayerUIView()
// Hide the iPadOS cursor while it hovers the video: the host renders its own
// cursor from our raw deltas, so the local one only diverges from it. (True
// pointer LOCK prefersPointerLocked isn't consulted through
// UIHostingController; this hides the pointer without locking it.)
let interaction = UIPointerInteraction(delegate: self)
view.addInteraction(interaction)
pointerInteraction = interaction
}
public func pointerInteraction(
_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion
) -> UIPointerStyle? {
captured ? .hidden() : nil
}
public override var prefersPointerLocked: Bool { captured }
@@ -168,6 +182,7 @@ public final class StreamViewController: UIViewController {
captured = false
}
setNeedsUpdateOfPrefersPointerLocked()
pointerInteraction?.invalidate() // re-resolve the hidden/visible pointer style
let onCaptureChange = onCaptureChange
let captured = captured
DispatchQueue.main.async { onCaptureChange?(captured) }