eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2import UIKit
3
4// πŸ€– The app is portrait-only on iPhone (project.yml), but a playing video
5// wants landscape fullscreen. This gate widens the allowed orientation mask
6// while the video lightbox is up: PushManager's
7// application(_:supportedInterfaceOrientationsFor:) consults it, so rotating
8// the phone rotates the player into landscape (and back) without unlocking
9// the rest of the app.
10enum VideoOrientation {
11 // Read by the app-delegate orientation callback. Main thread only β€” both
12 // the callback and the lightbox's appear/disappear run there.
13 private(set) static var allowsLandscape = false
14
15 static func setAllowsLandscape(_ on: Bool) {
16 allowsLandscape = on
17 // Make the system re-ask for the mask NOW β€” without this the change
18 // only takes effect on the next natural rotation, so closing the
19 // lightbox in landscape would leave the chat sideways.
20 scene?.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
21 }
22
23 /// Manual rotation (the YouTube-style expand/collapse button). A geometry
24 /// request rotates even when the user's Control Center rotation lock is
25 /// on β€” the lock only suppresses device-driven AUTO-rotation, which is
26 /// exactly why the button exists.
27 static func rotate(to orientations: UIInterfaceOrientationMask) {
28 scene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientations))
29 }
30
31 static var interfaceIsLandscape: Bool {
32 scene?.interfaceOrientation.isLandscape ?? false
33 }
34
35 private static var scene: UIWindowScene? {
36 let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
37 return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
38 }
39}
40
41// πŸ€– iOS has no public "is rotation lock on?" API, so infer it. While the
42// lightbox is up the app's mask allows landscape, so auto-rotation is expected
43// to WORK β€” if UIDevice reports the phone physically held landscape while the
44// interface stays portrait, something (in practice the Control Center rotation
45// lock) is blocking it, and the manual button is the only path to fullscreen.
46// The interface actually reaching landscape on its own proves rotation works.
47// A device lying flat (faceUp/faceDown/unknown) gives NO signal either way, so
48// the verdict starts .unknown and the button defaults to showing β€” a spare
49// button beats no path to fullscreen.
50final class RotationLockSensor: ObservableObject {
51 enum Verdict { case unknown, locked, unlocked }
52 @Published private(set) var verdict: Verdict = .unknown
53
54 // True briefly after the expand/collapse button fires β€” an interface that
55 // lands in landscape because WE asked proves nothing about the lock.
56 private var manualRotation = false
57 private var observer: NSObjectProtocol?
58
59 init() {
60 UIDevice.current.beginGeneratingDeviceOrientationNotifications()
61 observer = NotificationCenter.default.addObserver(
62 forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main
63 ) { [weak self] _ in
64 // Give auto-rotation a beat to actually turn the interface before
65 // comparing the two β€” reading immediately always sees the stale
66 // portrait interface and would cry "locked" on every phone.
67 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self?.assess() }
68 }
69 }
70
71 deinit {
72 if let observer { NotificationCenter.default.removeObserver(observer) }
73 // Balance the begin β€” on the main queue; deinit can run anywhere.
74 DispatchQueue.main.async { UIDevice.current.endGeneratingDeviceOrientationNotifications() }
75 }
76
77 /// Call when the expand/collapse button drives a rotation, so the sensor
78 /// doesn't read the resulting orientation change as evidence.
79 func noteManualRotation() {
80 manualRotation = true
81 DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
82 self?.manualRotation = false
83 }
84 }
85
86 private func assess() {
87 guard !manualRotation else { return }
88 let device = UIDevice.current.orientation
89 let interfaceLandscape = VideoOrientation.interfaceIsLandscape
90 if device.isLandscape && !interfaceLandscape {
91 verdict = .locked
92 } else if device.isLandscape && interfaceLandscape {
93 verdict = .unlocked
94 }
95 // Portrait or flat: no signal β€” keep the current verdict.
96 }
97}
98