Files
yattee/Yattee/Services/OrientationManager.swift
Arkadiusz Fal 40267573a9 Suspend the orientation lock while the Duo hinge is open
The in-app orientation lock pinned the player to the orientation it
opened in even where programmatic rotation is refused, so rotating the
half-open device did nothing. OrientationManager now ignores lock
requests and drops the current lock while canRotateProgrammatically is
false. The YATTEE_FORCE_TABLETOP override only applies on the interior
display size and flags the same state, matching a real .partiallyOpen
hinge instead of forcing the layout on the folded exterior display.
2026-10-01 22:35:47 +02:00

153 lines
5.4 KiB
Swift

//
// OrientationManager.swift
// Yattee
//
// Provides supported interface orientations for the app.
// Supports in-app orientation locking when the player is visible.
//
#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.
/// While false, the in-app orientation lock is suspended as well: the
/// system decides the orientation there, so pinning `supportedOrientations`
/// only fights the user's physical rotation (Duo hinge open, simulator
/// forced tabletop).
var canRotateProgrammatically = true {
didSet {
if !canRotateProgrammatically && isLocked {
unlock()
}
}
}
/// `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?
/// Whether orientation is currently locked.
private(set) var isLocked: Bool = false
/// Lock orientation to the current orientation
func lock() {
guard !isLocked, canRotateProgrammatically else { return }
lockedOrientation = currentInterfaceOrientationMask
isLocked = true
notifyOrientationChange()
}
/// Lock to a specific orientation
func lock(to orientation: UIInterfaceOrientationMask) {
guard canRotateProgrammatically else { return }
lockedOrientation = orientation
isLocked = true
notifyOrientationChange()
}
/// Re-lock to the current orientation (updates locked orientation even if already locked)
func lockToCurrentOrientation() {
guard canRotateProgrammatically else { return }
lockedOrientation = currentInterfaceOrientationMask
isLocked = true
notifyOrientationChange()
}
/// Unlock orientation to allow all rotations again
func unlock() {
guard isLocked else { return }
isLocked = false
lockedOrientation = nil
notifyOrientationChange()
}
// TODO: Modernization - Consider adopting `prefersInterfaceOrientationLocked` (iOS 26+)
// as the modern replacement for orientation locking via `supportedInterfaceOrientations`.
/// Returns the supported interface orientations.
/// When locked, returns only the locked orientation.
/// Otherwise: iPhone = all except upside-down, iPad = all
var supportedOrientations: UIInterfaceOrientationMask {
if isLocked, let locked = lockedOrientation {
return locked
}
if UIDevice.current.userInterfaceIdiom == .phone {
return .allButUpsideDown
} else {
return .all
}
}
/// Notify the system that supported orientations have changed
private func notifyOrientationChange() {
guard let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive })
else { return }
// Tell all view controllers to re-query supported orientations
for window in windowScene.windows {
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
/// Get the current interface orientation as a mask
var currentInterfaceOrientationMask: UIInterfaceOrientationMask {
guard let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive })
else {
return .allButUpsideDown
}
let screenBounds = windowScene.screen.bounds
if screenBounds.width > screenBounds.height {
// Currently landscape - determine which side based on interface orientation
let interfaceOrientation = windowScene.interfaceOrientation
switch interfaceOrientation {
case .landscapeLeft:
return .landscapeLeft
case .landscapeRight:
return .landscapeRight
default:
return .landscape
}
} else {
return .portrait
}
}
}
#endif