diff --git a/Yattee/Services/OrientationManager.swift b/Yattee/Services/OrientationManager.swift index 804516e3..25743d90 100644 --- a/Yattee/Services/OrientationManager.swift +++ b/Yattee/Services/OrientationManager.swift @@ -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? diff --git a/Yattee/Views/Player/ControlsSectionRenderer.swift b/Yattee/Views/Player/ControlsSectionRenderer.swift index b68b74c8..0c7ba687 100644 --- a/Yattee/Views/Player/ControlsSectionRenderer.swift +++ b/Yattee/Views/Player/ControlsSectionRenderer.swift @@ -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 diff --git a/Yattee/Views/Player/CornerPlayerLayout.swift b/Yattee/Views/Player/CornerPlayerLayout.swift index ccbfa12a..eb68cd32 100644 --- a/Yattee/Views/Player/CornerPlayerLayout.swift +++ b/Yattee/Views/Player/CornerPlayerLayout.swift @@ -192,11 +192,17 @@ struct CornerPlayerLayout: 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) diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index 9632e3f2..4567cd92 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -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 diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Orientation.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Orientation.swift index f83359bf..7ca635c2 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Orientation.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Orientation.swift @@ -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 }) diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 1e1f119a..3faa9a33 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -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 } } diff --git a/Yattee/Views/Player/ExpandedPlayerWindow.swift b/Yattee/Views/Player/ExpandedPlayerWindow.swift index 999dcaf1..29e1b38f 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindow.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindow.swift @@ -114,6 +114,22 @@ private final class ExpandedPlayerHostingController: 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 }) } diff --git a/Yattee/Views/Player/HingeLayoutObserver.swift b/Yattee/Views/Player/HingeLayoutObserver.swift index ffd946fe..8908851d 100644 --- a/Yattee/Views/Player/HingeLayoutObserver.swift +++ b/Yattee/Views/Player/HingeLayoutObserver.swift @@ -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) -> some View { modifier(HingeLayoutObserver(isTabletop: isTabletop)) } + + /// Writes whether the device has a hinge. No-op before iOS 27.1. + func observeHinge(hasHinge: Binding) -> some View { + modifier(HingePresenceObserver(hasHinge: hasHinge)) + } +} + +#else + +extension View { + func observeHinge(hasHinge: Binding) -> some View { self } } #endif diff --git a/Yattee/Views/Player/PlayerControlsActions.swift b/Yattee/Views/Player/PlayerControlsActions.swift index a699ac51..f018adfa 100644 --- a/Yattee/Views/Player/PlayerControlsActions.swift +++ b/Yattee/Views/Player/PlayerControlsActions.swift @@ -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 diff --git a/Yattee/Views/Player/PlayerControlsView.swift b/Yattee/Views/Player/PlayerControlsView.swift index 25a23feb..172a87ba 100644 --- a/Yattee/Views/Player/PlayerControlsView.swift +++ b/Yattee/Views/Player/PlayerControlsView.swift @@ -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, diff --git a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift index ee559ec3..ae10cd49 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift @@ -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