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

@@ -8,16 +8,41 @@
#if os(iOS)
import Observation
import UIKit
/// Provides supported interface orientations.
/// Supports in-app orientation locking to the current orientation.
@MainActor
@Observable
final class OrientationManager {
static let shared = OrientationManager()
private init() {}
/// Whether `requestGeometryUpdate(.iOS(interfaceOrientations:))` is honored
/// in the current windowing mode. False on the iPhone Duo interior display,
/// where the system refuses programmatic rotation (`UISceneErrorDomain` 101)
/// the same way it does for iPad multitasking. Set from the hinge state and
/// cleared when a request is refused, so the player falls back to the
/// iPad-style details toggle instead of doing nothing.
var canRotateProgrammatically = true
/// `UISceneErrorDomain` code returned when the windowing mode forbids
/// programmatic interface orientation changes.
static let orientationChangeForbiddenErrorCode = 101
/// Records a refused rotation request; returns true when the error is the
/// windowing-mode refusal (as opposed to a transient failure).
@discardableResult
func noteGeometryUpdateFailure(_ error: Error) -> Bool {
let nsError = error as NSError
guard nsError.domain == UISceneErrorDomain,
nsError.code == Self.orientationChangeForbiddenErrorCode else { return false }
canRotateProgrammatically = false
return true
}
/// The orientation to lock to when `isLocked` is true.
/// Updated when orientation lock is enabled to capture current orientation.
private var lockedOrientation: UIInterfaceOrientationMask?