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?

View File

@@ -134,8 +134,8 @@ struct ControlsSectionRenderer: View {
}
case .orientationLock:
// Only on iPhone (not iPad)
if !actions.isIPad, actions.onToggleOrientationLock != nil {
// Only where rotation is under the app's control (not iPad, not Duo interior)
if !actions.usesRotationFreeFullscreen, actions.onToggleOrientationLock != nil {
controlButton(
systemImage: actions.orientationLockIcon,
tint: actions.isOrientationLocked ? .red : .white

View File

@@ -192,11 +192,17 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
GeometryReader { outer in
let liveTopInset = outer.safeAreaInsets.top
#if os(iOS)
let knownTopInset = statusBarController?.knownStatusBarHeight ?? 0
// iPhone (Duo interior) never shows the status bar in this
// layout, so there is no strip to hold: pad by the live inset
// only. Remembering a past inset here would keep the 82pt top
// inset seen transiently while unfolding.
let holdsStatusBarStrip = UIDevice.current.userInterfaceIdiom == .pad
let knownTopInset = holdsStatusBarStrip ? (statusBarController?.knownStatusBarHeight ?? 0) : 0
#else
let holdsStatusBarStrip = true
let knownTopInset: CGFloat = 0
#endif
let topInset = liveTopInset > 0 ? liveTopInset : max(stableTopInset, knownTopInset)
let topInset = liveTopInset > 0 || !holdsStatusBarStrip ? liveTopInset : max(stableTopInset, knownTopInset)
layout(topInset: topInset)
.padding(.top, topInset)

View File

@@ -21,6 +21,13 @@ extension ExpandedPlayerSheet {
/// narrow windows).
static let minWidthForCornerLayout: CGFloat = 900
/// Minimum height (safe areas included) for the corner layout on iOS:
/// keeps it off tall-phone landscape (iPhone Pro Max is 956×440) while
/// the iPhone Duo interior (951×669) and every iPad qualify. The gate is
/// size-only — the Duo interior behaves like a small iPad, and the
/// exterior display never reaches the width.
static let minHeightForCornerLayout: CGFloat = 600
/// Tabletop (foldable, hinge partially open): vertical room reserved on
/// the lower half for the top bar row and for the bottom bar, keeping the
/// center controls and side sliders clear of both.
@@ -43,13 +50,15 @@ extension ExpandedPlayerSheet {
}
#if os(iOS) || os(macOS)
/// Whether the corner layout should be used for the given size: iPad/macOS
/// only, opted into via the preset's panel settings, wide enough, and in a
/// widescreen orientation. Evaluated per layout pass so window resizes and
/// Split View changes fall back automatically.
/// Whether the corner layout should be used for the given size: opted into
/// via the preset's panel settings, large enough (iPad, macOS, iPhone Duo
/// interior), and in a widescreen orientation. Evaluated per layout pass
/// so window resizes and Split View changes fall back automatically.
/// `size` must include the safe areas so the status bar toggling with the
/// layout can't feed back into the decision.
func isCornerLayout(size: CGSize) -> Bool {
#if os(iOS)
guard UIDevice.current.userInterfaceIdiom == .pad else { return false }
guard size.height >= Self.minHeightForCornerLayout else { return false }
#endif
return playerControlsLayout.effectivePanelSettings.effectiveLayoutMode == .corner
&& size.width >= Self.minWidthForCornerLayout

View File

@@ -38,6 +38,12 @@ extension ExpandedPlayerSheet {
/// When orientation lock is enabled, locks to the target orientation before rotating
/// (keeps orientation restricted the entire time, just changes which orientation is allowed).
func toggleFullscreen() {
// Windowing modes that refuse programmatic rotation (iPhone Duo
// interior) get the iPad behavior: toggle the details panel instead.
guard OrientationManager.shared.canRotateProgrammatically else {
togglePortraitDetailsPanel()
return
}
guard let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else { return }
@@ -51,6 +57,7 @@ extension ExpandedPlayerSheet {
fromSize: screenBounds.size,
toSize: isCurrentlyLandscape ? CGSize(width: screenBounds.height, height: screenBounds.width) : nil)
if isCurrentlyLandscape {
// Exit fullscreen → rotate to portrait
MPVLogging.log("toggleFullscreen: exiting to portrait")
@@ -58,7 +65,9 @@ extension ExpandedPlayerSheet {
if isOrientationLocked {
orientationManager.lock(to: .portrait)
}
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) { _ in }
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) { [self] error in
handleFullscreenRotationFailure(error)
}
} else {
// Enter fullscreen → rotate to landscape
let targetOrientation = Self.currentLandscapeInterfaceOrientation()
@@ -68,7 +77,9 @@ extension ExpandedPlayerSheet {
if isOrientationLocked {
orientationManager.lock(to: .landscape)
}
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: targetOrientation)) { _ in }
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: targetOrientation)) { [self] error in
handleFullscreenRotationFailure(error)
}
// After rotation completes, re-lock to the specific landscape orientation
// Use a delay since completion handler isn't reliable for waiting for rotation
if isOrientationLocked {
@@ -80,6 +91,36 @@ extension ExpandedPlayerSheet {
}
}
/// A refused `requestGeometryUpdate` means this windowing mode never
/// rotates programmatically; remember that and do what the tap was meant
/// to do — fill the screen with the video — via the details panel.
private func handleFullscreenRotationFailure(_ error: Error) {
MPVLogging.log("toggleFullscreen: requestGeometryUpdate failed: \(error.localizedDescription)")
guard OrientationManager.shared.noteGeometryUpdateFailure(error) else { return }
if appEnvironment?.settingsManager.inAppOrientationLock ?? false {
OrientationManager.shared.unlock()
}
togglePortraitDetailsPanel()
}
/// Fullscreen fallback when rotation isn't available: show/hide the
/// portrait details panel, mirroring `onToggleDetailsVisibility`.
private func togglePortraitDetailsPanel() {
hideControlsDuringTransition = true
if !isPortraitPanelVisible {
navigationCoordinator?.pinchPanscan = 0
}
withAnimation(.easeInOut(duration: 0.3)) {
isPortraitPanelVisible.toggle()
}
navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible
appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = isPortraitPanelVisible
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(350))
hideControlsDuringTransition = false
}
}
// MARK: - Rotation Monitoring
/// Simplified rotation monitoring - handles layout transitions via accelerometer.
@@ -93,7 +134,8 @@ extension ExpandedPlayerSheet {
rotationManager.onLandscapeDetected = { [weak appEnvironment] in
Task { @MainActor in
guard let appEnvironment else { return }
guard !appEnvironment.settingsManager.inAppOrientationLock else { return }
guard !appEnvironment.settingsManager.inAppOrientationLock,
OrientationManager.shared.canRotateProgrammatically else { return }
let playerState = appEnvironment.playerService.state
@@ -120,7 +162,8 @@ extension ExpandedPlayerSheet {
rotationManager.onPortraitDetected = { [weak appEnvironment] in
Task { @MainActor in
guard let appEnvironment else { return }
guard !appEnvironment.settingsManager.inAppOrientationLock else { return }
guard !appEnvironment.settingsManager.inAppOrientationLock,
OrientationManager.shared.canRotateProgrammatically else { return }
// Request portrait rotation
guard let windowScene = UIApplication.shared.connectedScenes
@@ -140,7 +183,8 @@ extension ExpandedPlayerSheet {
rotationManager.onLandscapeOrientationChanged = { [weak appEnvironment] newOrientation in
Task { @MainActor in
guard let appEnvironment else { return }
guard !appEnvironment.settingsManager.inAppOrientationLock else { return }
guard !appEnvironment.settingsManager.inAppOrientationLock,
OrientationManager.shared.canRotateProgrammatically else { return }
guard let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })

View File

@@ -307,6 +307,18 @@ struct ExpandedPlayerSheet: View {
.ignoresSafeArea(edges: .bottom)
}
#if os(iOS)
/// Whether the status bar and home indicator are hidden for the current
/// layout state (see the modifier site for the rationale).
private var hidesSystemOverlays: Bool {
if navigationCoordinator?.isCornerLayoutActive == true {
return UIDevice.current.userInterfaceIdiom == .phone
|| !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
}
return isInWideScreenLayout || !isPortraitPanelVisible
}
#endif
// MARK: - Player Content
@ViewBuilder
@@ -314,7 +326,13 @@ struct ExpandedPlayerSheet: View {
GeometryReader { geometry in
let wideScreen = isWideScreenLayout(size: geometry.size)
#if os(iOS) || os(macOS)
let cornerActive = isCornerLayout(size: geometry.size)
// Gate on the full size including safe areas: the corner layout
// shows the status bar, which would otherwise shrink the measured
// height and flip the decision back (layout oscillation).
let cornerActive = isCornerLayout(size: CGSize(
width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing,
height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom
))
#endif
ZStack(alignment: .top) {
@@ -480,18 +498,13 @@ struct ExpandedPlayerSheet: View {
#if os(iOS)
.toolbar(.hidden, for: .navigationBar)
// Corner layout with panels visible keeps the system status bar and
// home indicator; hiding the panels (fullscreen state) hides them so
// the video and controls reach the screen edges.
.playerStatusBarHidden(
navigationCoordinator?.isCornerLayoutActive == true
? !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
: (isInWideScreenLayout || !isPortraitPanelVisible)
)
.persistentSystemOverlays(
(navigationCoordinator?.isCornerLayoutActive == true
? !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
: (isInWideScreenLayout || !isPortraitPanelVisible)) ? .hidden : .automatic
)
// home indicator on iPad (a thin strip); hiding the panels (fullscreen
// state) hides them so the video and controls reach the screen edges.
// On the iPhone Duo interior the landscape status bar is a ~57pt
// column plus top row that would inset the whole layout, so the
// corner layout always hides it there.
.playerStatusBarHidden(hidesSystemOverlays)
.persistentSystemOverlays(hidesSystemOverlays ? .hidden : .automatic)
#endif
}
}

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
})
}

View File

@@ -25,6 +25,11 @@ struct HingeLayoutObserver: ViewModifier {
content.onHingeChange { _, context in
// `hinge` is nil on devices without a hinge and when the view
// leaves a hierarchy that provides hinge updates.
if let status = context.hinge?.status {
// Interior display (hinge open): the system refuses
// programmatic rotation, so the player must not offer it.
OrientationManager.shared.canRotateProgrammatically = status == .closed
}
let tabletop = context.hinge?.status == .partiallyOpen
guard tabletop != isTabletop else { return }
withAnimation(.easeInOut(duration: 0.3)) {
@@ -37,12 +42,41 @@ struct HingeLayoutObserver: ViewModifier {
}
}
/// Reports whether the device has a hinge at all (foldable iPhone), for UI
/// that only applies to the interior display, such as the corner layout option.
private struct HingePresenceObserver: ViewModifier {
@Binding var hasHinge: Bool
func body(content: Content) -> some View {
if #available(iOS 27.1, *) {
content.onHingeChange { _, context in
// nil also when leaving a hinge-providing hierarchy; keep the last value then
guard context.hinge != nil else { return }
hasHinge = true
}
} else {
content
}
}
}
extension View {
/// Tracks the device hinge and writes whether the tabletop player layout
/// should be active. No-op before iOS 27.1 and on devices without a hinge.
func observeHingeLayout(isTabletop: Binding<Bool>) -> some View {
modifier(HingeLayoutObserver(isTabletop: isTabletop))
}
/// Writes whether the device has a hinge. No-op before iOS 27.1.
func observeHinge(hasHinge: Binding<Bool>) -> some View {
modifier(HingePresenceObserver(hasHinge: hasHinge))
}
}
#else
extension View {
func observeHinge(hasHinge: Binding<Bool>) -> some View { self }
}
#endif

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

View File

@@ -422,6 +422,7 @@ struct PlayerControlsView: View {
isCornerLayout: isCornerLayout,
panelSide: panelSide,
isIPad: isIPad,
canRotate: OrientationManager.shared.canRotateProgrammatically,
showVolumeControls: showVolumeControls,
showDebugButton: onToggleDebug != nil,
showCloseButton: onClose != nil,

View File

@@ -22,7 +22,8 @@ struct PanelSettingsView: View {
@State private var commentsShowAvatars: Bool = true
@State private var commentsPillMode: CommentsPillMode = .pill
/// Corner layout exists on iPad and macOS only.
/// Corner layout exists on iPad and macOS, and on a foldable iPhone whose
/// interior display is large enough (see `hasHinge`).
static var supportsCornerLayout: Bool {
#if os(macOS)
return true
@@ -31,12 +32,17 @@ struct PanelSettingsView: View {
#endif
}
/// Foldable iPhone: the interior display qualifies for the corner layout.
@State private var hasHinge = false
private var showsLayoutSection: Bool { Self.supportsCornerLayout || hasHinge }
/// iPhone has no page bar: the list configures inline sections instead.
private var usesSections: Bool { PanelSettings.isSectionsOnlyDevice }
var body: some View {
List {
if Self.supportsCornerLayout {
if showsLayoutSection {
layoutSection
}
visibilitySection
@@ -66,6 +72,7 @@ struct PanelSettingsView: View {
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncLocalState()
}
.observeHinge(hasHinge: $hasHinge)
}
// MARK: - Layout Section