mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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.
153 lines
5.4 KiB
Swift
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
|