mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
266 lines
10 KiB
Swift
266 lines
10 KiB
Swift
//
|
|
// DeviceRotationManager.swift
|
|
// Yattee
|
|
//
|
|
// Monitors device rotation using accelerometer to detect landscape orientation
|
|
// even when screen rotation is locked.
|
|
//
|
|
|
|
#if os(iOS)
|
|
import CoreMotion
|
|
import SwiftUI
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class DeviceRotationManager {
|
|
static let shared = DeviceRotationManager()
|
|
|
|
private let motionManager = CMMotionManager()
|
|
private(set) var isMonitoring = false
|
|
|
|
/// Whether monitoring was active before going to background
|
|
private var wasMonitoringBeforeBackground = false
|
|
|
|
/// Current scene phase - used to prevent orientation changes while in background
|
|
private var currentScenePhase: ScenePhase = .active
|
|
|
|
/// Timestamp of last rotation callback to prevent rapid-fire orientation changes
|
|
private var lastRotationCallbackTime: Date?
|
|
|
|
/// Cooldown period in seconds before allowing next rotation callback
|
|
/// This prevents triggering callbacks while previous animation is still in progress
|
|
private let rotationCooldown: TimeInterval = 0.6
|
|
|
|
/// Pending rotation check task - used to defer rotation until animation completes
|
|
private var pendingRotationTask: Task<Void, Never>?
|
|
|
|
/// Current detected orientation based on accelerometer
|
|
private(set) var detectedOrientation: UIDeviceOrientation = .portrait
|
|
|
|
/// Callback when device is rotated to landscape (from portrait)
|
|
var onLandscapeDetected: (() -> Void)?
|
|
|
|
/// Callback when device is rotated to portrait
|
|
var onPortraitDetected: (() -> Void)?
|
|
|
|
/// Callback when device is rotated between landscape orientations (left <-> right)
|
|
var onLandscapeOrientationChanged: ((UIDeviceOrientation) -> Void)?
|
|
|
|
/// Callback to check if in-app orientation lock is enabled.
|
|
/// When this returns true, rotation callbacks are suppressed.
|
|
/// Set by ExpandedPlayerSheet when visible, cleared when dismissed.
|
|
var isOrientationLocked: (() -> Bool)?
|
|
|
|
private init() {}
|
|
|
|
/// Start monitoring device orientation via accelerometer
|
|
func startMonitoring() {
|
|
guard motionManager.isAccelerometerAvailable else { return }
|
|
|
|
// Sync detected orientation with current screen orientation
|
|
syncWithScreenOrientation()
|
|
|
|
// If already monitoring, just return (callbacks may have been updated)
|
|
guard !isMonitoring else { return }
|
|
|
|
isMonitoring = true
|
|
motionManager.accelerometerUpdateInterval = 0.2 // 5 times per second
|
|
|
|
motionManager.startAccelerometerUpdates(to: .main) { [weak self] data, error in
|
|
guard let self, let data else { return }
|
|
|
|
Task { @MainActor in
|
|
self.processAccelerometerData(data)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sync detected orientation with current screen orientation
|
|
/// Call this when entering fullscreen to ensure portrait detection works
|
|
func syncWithScreenOrientation() {
|
|
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 {
|
|
// Screen is in landscape - set detected to landscape so portrait detection works
|
|
if detectedOrientation != .landscapeLeft && detectedOrientation != .landscapeRight {
|
|
detectedOrientation = .landscapeLeft
|
|
}
|
|
} else {
|
|
// Screen is in portrait
|
|
if detectedOrientation != .portrait && detectedOrientation != .portraitUpsideDown {
|
|
detectedOrientation = .portrait
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stop monitoring device orientation
|
|
func stopMonitoring() {
|
|
guard isMonitoring else { return }
|
|
|
|
isMonitoring = false
|
|
motionManager.stopAccelerometerUpdates()
|
|
pendingRotationTask?.cancel()
|
|
pendingRotationTask = nil
|
|
}
|
|
|
|
/// Handle scene phase changes - stops monitoring in background to prevent
|
|
/// fullscreen entry while app is not visible
|
|
func handleScenePhase(_ phase: ScenePhase) {
|
|
let previousPhase = currentScenePhase
|
|
currentScenePhase = phase
|
|
|
|
switch phase {
|
|
case .background, .inactive:
|
|
// Stop accelerometer when going to background
|
|
if isMonitoring {
|
|
wasMonitoringBeforeBackground = true
|
|
motionManager.stopAccelerometerUpdates()
|
|
isMonitoring = false
|
|
}
|
|
|
|
case .active:
|
|
// Restart monitoring if it was active before backgrounding
|
|
if wasMonitoringBeforeBackground && previousPhase != .active {
|
|
wasMonitoringBeforeBackground = false
|
|
// Sync orientation with current screen state before restarting
|
|
syncWithScreenOrientation()
|
|
startMonitoring()
|
|
}
|
|
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func processAccelerometerData(_ data: CMAccelerometerData) {
|
|
// If in-app orientation lock is enabled, don't process rotation callbacks
|
|
if isOrientationLocked?() == true {
|
|
return
|
|
}
|
|
|
|
let x = data.acceleration.x
|
|
let y = data.acceleration.y
|
|
|
|
// Determine orientation based on gravity direction
|
|
// Threshold to avoid triggering on slight tilts
|
|
let threshold = 0.6
|
|
|
|
let newOrientation: UIDeviceOrientation
|
|
|
|
if abs(x) > abs(y) {
|
|
// Landscape orientation
|
|
if x > threshold {
|
|
newOrientation = .landscapeRight
|
|
} else if x < -threshold {
|
|
newOrientation = .landscapeLeft
|
|
} else {
|
|
return // Not tilted enough
|
|
}
|
|
} else {
|
|
// Portrait orientation
|
|
if y < -threshold {
|
|
newOrientation = .portrait
|
|
} else if y > threshold {
|
|
newOrientation = .portraitUpsideDown
|
|
} else {
|
|
return // Not tilted enough
|
|
}
|
|
}
|
|
|
|
// Only trigger if orientation changed
|
|
guard newOrientation != detectedOrientation else { return }
|
|
|
|
let previousOrientation = detectedOrientation
|
|
detectedOrientation = newOrientation
|
|
|
|
// Only trigger callbacks when app is active - prevents fullscreen entry
|
|
// while app is in background (e.g., when rotating device during audio-only playback)
|
|
guard currentScenePhase == .active else { return }
|
|
|
|
// Trigger callbacks
|
|
let wasPortrait = previousOrientation == .portrait || previousOrientation == .portraitUpsideDown
|
|
let wasLandscape = previousOrientation == .landscapeLeft || previousOrientation == .landscapeRight
|
|
let isLandscape = newOrientation == .landscapeLeft || newOrientation == .landscapeRight
|
|
let isPortrait = newOrientation == .portrait
|
|
|
|
// Check cooldown - if previous animation still in progress, schedule deferred check
|
|
if let lastTime = lastRotationCallbackTime,
|
|
Date().timeIntervalSince(lastTime) < rotationCooldown {
|
|
scheduleDeferredRotationCheck(remainingCooldown: rotationCooldown - Date().timeIntervalSince(lastTime))
|
|
return
|
|
}
|
|
|
|
if wasPortrait && isLandscape {
|
|
lastRotationCallbackTime = Date()
|
|
onLandscapeDetected?()
|
|
} else if isPortrait && !wasPortrait {
|
|
lastRotationCallbackTime = Date()
|
|
onPortraitDetected?()
|
|
} else if wasLandscape && isLandscape && previousOrientation != newOrientation {
|
|
// Landscape to landscape rotation (left <-> right)
|
|
// No cooldown for this - it's just updating the orientation, not a full screen transition
|
|
onLandscapeOrientationChanged?(newOrientation)
|
|
}
|
|
}
|
|
|
|
/// Schedule a deferred rotation check after cooldown expires
|
|
/// This ensures we don't miss a rotation that happened during animation
|
|
private func scheduleDeferredRotationCheck(remainingCooldown: TimeInterval) {
|
|
// Cancel any existing pending check
|
|
pendingRotationTask?.cancel()
|
|
|
|
pendingRotationTask = Task { @MainActor [weak self] in
|
|
// Wait for remaining cooldown
|
|
try? await Task.sleep(nanoseconds: UInt64(remainingCooldown * 1_000_000_000))
|
|
|
|
guard !Task.isCancelled, let self else { return }
|
|
|
|
// Check current orientation and trigger callback if needed
|
|
self.performDeferredRotationCheck()
|
|
}
|
|
}
|
|
|
|
/// Perform deferred rotation check - called after cooldown expires
|
|
private func performDeferredRotationCheck() {
|
|
guard currentScenePhase == .active else { return }
|
|
|
|
// Get current screen orientation to compare against detected orientation
|
|
guard let windowScene = UIApplication.shared.connectedScenes
|
|
.compactMap({ $0 as? UIWindowScene })
|
|
.first(where: { $0.activationState == .foregroundActive })
|
|
else { return }
|
|
|
|
let screenBounds = windowScene.screen.bounds
|
|
let screenIsLandscape = screenBounds.width > screenBounds.height
|
|
|
|
let detectedIsLandscape = detectedOrientation == .landscapeLeft || detectedOrientation == .landscapeRight
|
|
let detectedIsPortrait = detectedOrientation == .portrait || detectedOrientation == .portraitUpsideDown
|
|
|
|
// If device orientation differs from screen orientation, trigger the appropriate callback
|
|
if detectedIsLandscape && !screenIsLandscape {
|
|
// Device is in landscape but screen is portrait - enter fullscreen
|
|
lastRotationCallbackTime = Date()
|
|
onLandscapeDetected?()
|
|
} else if detectedIsPortrait && screenIsLandscape {
|
|
// Device is in portrait but screen is landscape - exit fullscreen
|
|
lastRotationCallbackTime = Date()
|
|
onPortraitDetected?()
|
|
}
|
|
}
|
|
|
|
/// Check if device is currently in landscape orientation
|
|
var isLandscape: Bool {
|
|
detectedOrientation == .landscapeLeft || detectedOrientation == .landscapeRight
|
|
}
|
|
|
|
/// Check if device is currently in portrait orientation
|
|
var isPortrait: Bool {
|
|
detectedOrientation == .portrait || detectedOrientation == .portraitUpsideDown
|
|
}
|
|
}
|
|
#endif
|