Files
yattee/Yattee/Views/Player/ExpandedPlayerSheet+Orientation.swift
Arkadiusz Fal 2fd52fff27 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.
2026-10-01 08:53:37 +02:00

229 lines
9.9 KiB
Swift

//
// ExpandedPlayerSheet+Orientation.swift
// Yattee
//
// iOS-specific orientation, fullscreen, and ambient glow functionality.
//
import SwiftUI
#if os(iOS)
import UIKit
extension ExpandedPlayerSheet {
// MARK: - Safe Area Helpers
/// Get safe area insets from the window.
var windowSafeAreaInsets: UIEdgeInsets {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?
.windows
.first?
.safeAreaInsets ?? .zero
}
// MARK: - Orientation Lock
/// Set up in-app orientation lock callback.
func setupOrientationLockCallback() {
DeviceRotationManager.shared.isOrientationLocked = { [weak appEnvironment] in
appEnvironment?.settingsManager.inAppOrientationLock ?? false
}
}
// MARK: - Fullscreen Toggle
/// Toggle fullscreen by rotating between portrait and landscape.
/// 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 }
let screenBounds = windowScene.screen.bounds
let isCurrentlyLandscape = screenBounds.width > screenBounds.height
let orientationManager = OrientationManager.shared
let isOrientationLocked = appEnvironment?.settingsManager.inAppOrientationLock ?? false
MPVLogging.logTransition("toggleFullscreen",
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")
// Lock to portrait first (if lock enabled) so system allows only portrait rotation
if isOrientationLocked {
orientationManager.lock(to: .portrait)
}
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) { [self] error in
handleFullscreenRotationFailure(error)
}
} else {
// Enter fullscreen → rotate to landscape
let targetOrientation = Self.currentLandscapeInterfaceOrientation()
MPVLogging.log("toggleFullscreen: entering landscape")
// Lock to landscape first (if lock enabled) so system allows landscape rotation
// Use .landscape to allow both directions initially
if isOrientationLocked {
orientationManager.lock(to: .landscape)
}
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 {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
orientationManager.lockToCurrentOrientation()
}
}
}
}
/// 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.
func setupRotationMonitoring() {
guard let appEnvironment else { return }
MPVLogging.log("setupRotationMonitoring: starting")
let rotationManager = DeviceRotationManager.shared
// Set up callback for landscape detection (request landscape rotation)
rotationManager.onLandscapeDetected = { [weak appEnvironment] in
Task { @MainActor in
guard let appEnvironment else { return }
guard !appEnvironment.settingsManager.inAppOrientationLock,
OrientationManager.shared.canRotateProgrammatically else { return }
let playerState = appEnvironment.playerService.state
// Only rotate if we have a video playing
guard playerState.currentVideo != nil,
playerState.pipState != .active else { return }
// Request landscape rotation
guard let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else { return }
let screenBounds = windowScene.screen.bounds
if screenBounds.height > screenBounds.width {
let targetOrientation = Self.currentLandscapeInterfaceOrientation()
MPVLogging.logTransition("onLandscapeDetected: requesting landscape",
fromSize: screenBounds.size, toSize: nil)
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: targetOrientation)) { _ in }
}
}
}
// Set up callback for portrait detection (request portrait rotation)
rotationManager.onPortraitDetected = { [weak appEnvironment] in
Task { @MainActor in
guard let appEnvironment else { return }
guard !appEnvironment.settingsManager.inAppOrientationLock,
OrientationManager.shared.canRotateProgrammatically else { return }
// Request portrait rotation
guard let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else { return }
let screenBounds = windowScene.screen.bounds
if screenBounds.width > screenBounds.height {
MPVLogging.logTransition("onPortraitDetected: requesting portrait",
fromSize: screenBounds.size, toSize: nil)
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) { _ in }
}
}
}
// Set up callback for landscape-to-landscape rotation (rotate between left and right)
rotationManager.onLandscapeOrientationChanged = { [weak appEnvironment] newOrientation in
Task { @MainActor in
guard let appEnvironment else { return }
guard !appEnvironment.settingsManager.inAppOrientationLock,
OrientationManager.shared.canRotateProgrammatically else { return }
guard let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else { return }
// Device and interface orientations are inverted
let targetOrientation: UIInterfaceOrientationMask = switch newOrientation {
case .landscapeLeft:
.landscapeRight
case .landscapeRight:
.landscapeLeft
default:
.landscape
}
MPVLogging.logTransition("onLandscapeOrientationChanged: \(newOrientation.rawValue)")
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: targetOrientation)) { _ in }
}
}
// Always start monitoring
rotationManager.startMonitoring()
}
/// Get the interface orientation mask matching the device's current landscape orientation.
static func currentLandscapeInterfaceOrientation() -> UIInterfaceOrientationMask {
let deviceOrientation = DeviceRotationManager.shared.detectedOrientation
// Device and interface orientations are inverted
switch deviceOrientation {
case .landscapeLeft:
return .landscapeRight
case .landscapeRight:
return .landscapeLeft
default:
return .landscape // Fallback to any landscape
}
}
}
#endif