Support the iPhone Duo interior display in the player

The interior display refuses programmatic rotation
(UISceneErrorDomain 101: "The current windowing mode does not allow for
programmatic changes to interface orientation"), so the rotate/fullscreen
button did nothing there and the failure was swallowed.

- OrientationManager tracks `canRotateProgrammatically` (from the hinge
  state, cleared when a geometry request is refused) and is @Observable.
- The fullscreen button takes the iPad path when rotation is unavailable:
  details toggle in portrait, floating panel toggle in landscape, icon
  follows the panel state; orientation lock is hidden. The refused request
  itself falls back to the details toggle. Accelerometer-driven rotation
  requests are gated the same way.
- Corner layout gate is size-only on iOS (≥900×600 including safe areas,
  so the status bar toggling with the layout can't oscillate it); the
  layout-mode setting is exposed on foldables via a hinge presence check.
- Corner layout on iPhone always hides the status bar (the interior's
  landscape bar is a ~57pt column) and pads by the live top inset only,
  instead of the transient 82pt inset seen while unfolding.
- The player hosting controller nudges its safe area after size
  transitions so fold/unfold doesn't leave SwiftUI with stale insets.
This commit is contained in:
Arkadiusz Fal
2026-10-01 08:53:37 +02:00
parent 2d65bc756f
commit 2fd52fff27
11 changed files with 217 additions and 37 deletions

View File

@@ -114,6 +114,22 @@ private final class ExpandedPlayerHostingController<Content: View>: UIHostingCon
guard let window = self?.view.window,
let scene = window.windowScene else { return }
window.frame = scene.coordinateSpace.bounds
}, completion: { [weak self] _ in
// Folding/unfolding the iPhone Duo (and display handoffs in
// general) changes the safe area without a rotation; SwiftUI
// keeps serving the previous insets to the sheet's GeometryReader
// until the hosting controller's safe area is invalidated. Nudge
// additionalSafeAreaInsets, the same trick the dismiss path uses
// for the main window.
guard let self else { return }
let originalInsets = self.additionalSafeAreaInsets
self.additionalSafeAreaInsets = UIEdgeInsets(
top: originalInsets.top + 1,
left: originalInsets.left,
bottom: originalInsets.bottom,
right: originalInsets.right
)
self.additionalSafeAreaInsets = originalInsets
})
}