import SwiftUI import UIKit // 🤖 The app is portrait-only on iPhone (project.yml), but a playing video // wants landscape fullscreen. This gate widens the allowed orientation mask // while the video lightbox is up: PushManager's // application(_:supportedInterfaceOrientationsFor:) consults it, so rotating // the phone rotates the player into landscape (and back) without unlocking // the rest of the app. enum VideoOrientation { // Read by the app-delegate orientation callback. Main thread only — both // the callback and the lightbox's appear/disappear run there. private(set) static var allowsLandscape = false static func setAllowsLandscape(_ on: Bool) { allowsLandscape = on // Make the system re-ask for the mask NOW — without this the change // only takes effect on the next natural rotation, so closing the // lightbox in landscape would leave the chat sideways. scene?.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() } /// Manual rotation (the YouTube-style expand/collapse button). A geometry /// request rotates even when the user's Control Center rotation lock is /// on — the lock only suppresses device-driven AUTO-rotation, which is /// exactly why the button exists. static func rotate(to orientations: UIInterfaceOrientationMask) { scene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientations)) } static var interfaceIsLandscape: Bool { scene?.interfaceOrientation.isLandscape ?? false } private static var scene: UIWindowScene? { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first } } // 🤖 iOS has no public "is rotation lock on?" API, so infer it. While the // lightbox is up the app's mask allows landscape, so auto-rotation is expected // to WORK — if UIDevice reports the phone physically held landscape while the // interface stays portrait, something (in practice the Control Center rotation // lock) is blocking it, and the manual button is the only path to fullscreen. // The interface actually reaching landscape on its own proves rotation works. // A device lying flat (faceUp/faceDown/unknown) gives NO signal either way, so // the verdict starts .unknown and the button defaults to showing — a spare // button beats no path to fullscreen. final class RotationLockSensor: ObservableObject { enum Verdict { case unknown, locked, unlocked } @Published private(set) var verdict: Verdict = .unknown // True briefly after the expand/collapse button fires — an interface that // lands in landscape because WE asked proves nothing about the lock. private var manualRotation = false private var observer: NSObjectProtocol? init() { UIDevice.current.beginGeneratingDeviceOrientationNotifications() observer = NotificationCenter.default.addObserver( forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main ) { [weak self] _ in // Give auto-rotation a beat to actually turn the interface before // comparing the two — reading immediately always sees the stale // portrait interface and would cry "locked" on every phone. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self?.assess() } } } deinit { if let observer { NotificationCenter.default.removeObserver(observer) } // Balance the begin — on the main queue; deinit can run anywhere. DispatchQueue.main.async { UIDevice.current.endGeneratingDeviceOrientationNotifications() } } /// Call when the expand/collapse button drives a rotation, so the sensor /// doesn't read the resulting orientation change as evidence. func noteManualRotation() { manualRotation = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.manualRotation = false } } private func assess() { guard !manualRotation else { return } let device = UIDevice.current.orientation let interfaceLandscape = VideoOrientation.interfaceIsLandscape if device.isLandscape && !interfaceLandscape { verdict = .locked } else if device.isLandscape && interfaceLandscape { verdict = .unlocked } // Portrait or flat: no signal — keep the current verdict. } }