Files
yattee/Yattee/Services/DeviceRotationManager.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

261 lines
9.9 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 portrait orientation
var isPortrait: Bool {
detectedOrientation == .portrait || detectedOrientation == .portraitUpsideDown
}
}
#endif