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

@@ -51,6 +51,20 @@ struct PlayerControlsActions {
#if os(iOS)
/// Whether running on iPad
let isIPad: Bool
/// Whether the system honors programmatic rotation requests. False on the
/// iPhone Duo interior display, which then gets the iPad fullscreen
/// behavior (details toggle, no rotate glyph, no orientation lock).
var canRotate: Bool = true
/// iPad, or an iPhone windowing mode that can't rotate on request.
var usesRotationFreeFullscreen: Bool { isIPad || !canRotate }
/// Rotation-free fullscreen in a layout without a details toggle: the
/// button shows/hides the panel instead, so the icon tracks the panel.
var togglesPanelOnFullscreen: Bool {
usesRotationFreeFullscreen && onToggleDetailsVisibility == nil && onTogglePanel != nil
}
#endif
/// Whether volume controls should show (mpv mode)
@@ -271,8 +285,9 @@ struct PlayerControlsActions {
/// Uses rotation icons when tapping will cause device rotation,
/// otherwise uses standard fullscreen arrows.
var fullscreenIcon: String {
if isCornerLayout {
// Corner layout: reflects the panels state, not orientation
if isCornerLayout || togglesPanelOnFullscreen {
// Corner layout / rotation-free widescreen: reflects the panel
// state, not orientation
return isPanelVisible ? "arrow.up.left.and.arrow.down.right" : "arrow.down.right.and.arrow.up.left"
}
if willRotateOnFullscreenToggle {
@@ -302,9 +317,16 @@ struct PlayerControlsActions {
let isActualWidescreenLayout = isWideScreenLayout && onTogglePanel != nil
if isIPad {
// iPad: always toggle details visibility
onToggleDetailsVisibility?()
if usesRotationFreeFullscreen {
// iPad (or no programmatic rotation): toggle details visibility.
// The widescreen layout has no details toggle — there fullscreen
// means hiding the floating panel, like the corner layout.
if let onToggleDetailsVisibility {
onToggleDetailsVisibility()
} else if togglesPanelOnFullscreen {
onHideControls?()
onTogglePanel?()
}
} else if isActualWidescreenLayout && isFullscreen && !isWidescreenVideo {
// iPhone in landscape with portrait video fullscreen: rotate back to portrait
onToggleFullscreen?()
@@ -320,8 +342,9 @@ struct PlayerControlsActions {
/// Whether tapping fullscreen will cause device rotation.
/// Mirrors the logic in `performFullscreenTap()`
var willRotateOnFullscreenToggle: Bool {
// iPad never rotates via fullscreen button
guard !isIPad else { return false }
// iPad never rotates via fullscreen button; neither does a windowing
// mode that refuses programmatic rotation
guard !usesRotationFreeFullscreen else { return false }
let isActualWidescreenLayout = isWideScreenLayout && onTogglePanel != nil
@@ -359,7 +382,9 @@ struct PlayerControlsActions {
return onTogglePanel != nil
}
// iPad widescreen (true landscape): hidden (can't force rotation)
// iPad widescreen (true landscape): hidden (can't force rotation).
// Only real iPads: a rotation-free iPhone (Duo interior) with the
// panel hidden still needs the button to bring the panel back.
let isActualWidescreen = isWideScreenLayout && onTogglePanel != nil
let showFullscreenButton = !(isIPad && isActualWidescreen)
let hasFullscreenAction = onToggleFullscreen != nil || onToggleDetailsVisibility != nil