mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
The interior display refuses programmatic rotation (UISceneErrorDomain 101: "The current windowing mode does not allow for programmatic changes to interface orientation"), so the rotate/fullscreen button did nothing there and the failure was swallowed. - OrientationManager tracks `canRotateProgrammatically` (from the hinge state, cleared when a geometry request is refused) and is @Observable. - The fullscreen button takes the iPad path when rotation is unavailable: details toggle in portrait, floating panel toggle in landscape, icon follows the panel state; orientation lock is hidden. The refused request itself falls back to the details toggle. Accelerometer-driven rotation requests are gated the same way. - Corner layout gate is size-only on iOS (≥900×600 including safe areas, so the status bar toggling with the layout can't oscillate it); the layout-mode setting is exposed on foldables via a hinge presence check. - Corner layout on iPhone always hides the status bar (the interior's landscape bar is a ~57pt column) and pads by the live top inset only, instead of the transient 82pt inset seen while unfolding. - The player hosting controller nudges its safe area after size transitions so fold/unfold doesn't leave SwiftUI with stale insets.
229 lines
9.9 KiB
Swift
229 lines
9.9 KiB
Swift
//
|
|
// ExpandedPlayerSheet+Orientation.swift
|
|
// Yattee
|
|
//
|
|
// iOS-specific orientation, fullscreen, and ambient glow functionality.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
|
|
extension ExpandedPlayerSheet {
|
|
// MARK: - Safe Area Helpers
|
|
|
|
/// Get safe area insets from the window.
|
|
var windowSafeAreaInsets: UIEdgeInsets {
|
|
UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.first?
|
|
.windows
|
|
.first?
|
|
.safeAreaInsets ?? .zero
|
|
}
|
|
|
|
// MARK: - Orientation Lock
|
|
|
|
/// Set up in-app orientation lock callback.
|
|
func setupOrientationLockCallback() {
|
|
DeviceRotationManager.shared.isOrientationLocked = { [weak appEnvironment] in
|
|
appEnvironment?.settingsManager.inAppOrientationLock ?? false
|
|
}
|
|
}
|
|
|
|
// MARK: - Fullscreen Toggle
|
|
|
|
/// Toggle fullscreen by rotating between portrait and landscape.
|
|
/// When orientation lock is enabled, locks to the target orientation before rotating
|
|
/// (keeps orientation restricted the entire time, just changes which orientation is allowed).
|
|
func toggleFullscreen() {
|
|
// Windowing modes that refuse programmatic rotation (iPhone Duo
|
|
// interior) get the iPad behavior: toggle the details panel instead.
|
|
guard OrientationManager.shared.canRotateProgrammatically else {
|
|
togglePortraitDetailsPanel()
|
|
return
|
|
}
|
|
guard let windowScene = UIApplication.shared.connectedScenes
|
|
.compactMap({ $0 as? UIWindowScene })
|
|
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
|
|
|
let screenBounds = windowScene.screen.bounds
|
|
let isCurrentlyLandscape = screenBounds.width > screenBounds.height
|
|
let orientationManager = OrientationManager.shared
|
|
let isOrientationLocked = appEnvironment?.settingsManager.inAppOrientationLock ?? false
|
|
|
|
MPVLogging.logTransition("toggleFullscreen",
|
|
fromSize: screenBounds.size,
|
|
toSize: isCurrentlyLandscape ? CGSize(width: screenBounds.height, height: screenBounds.width) : nil)
|
|
|
|
|
|
if isCurrentlyLandscape {
|
|
// Exit fullscreen → rotate to portrait
|
|
MPVLogging.log("toggleFullscreen: exiting to portrait")
|
|
// Lock to portrait first (if lock enabled) so system allows only portrait rotation
|
|
if isOrientationLocked {
|
|
orientationManager.lock(to: .portrait)
|
|
}
|
|
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) { [self] error in
|
|
handleFullscreenRotationFailure(error)
|
|
}
|
|
} else {
|
|
// Enter fullscreen → rotate to landscape
|
|
let targetOrientation = Self.currentLandscapeInterfaceOrientation()
|
|
MPVLogging.log("toggleFullscreen: entering landscape")
|
|
// Lock to landscape first (if lock enabled) so system allows landscape rotation
|
|
// Use .landscape to allow both directions initially
|
|
if isOrientationLocked {
|
|
orientationManager.lock(to: .landscape)
|
|
}
|
|
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: targetOrientation)) { [self] error in
|
|
handleFullscreenRotationFailure(error)
|
|
}
|
|
// After rotation completes, re-lock to the specific landscape orientation
|
|
// Use a delay since completion handler isn't reliable for waiting for rotation
|
|
if isOrientationLocked {
|
|
Task { @MainActor in
|
|
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
|
|
orientationManager.lockToCurrentOrientation()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A refused `requestGeometryUpdate` means this windowing mode never
|
|
/// rotates programmatically; remember that and do what the tap was meant
|
|
/// to do — fill the screen with the video — via the details panel.
|
|
private func handleFullscreenRotationFailure(_ error: Error) {
|
|
MPVLogging.log("toggleFullscreen: requestGeometryUpdate failed: \(error.localizedDescription)")
|
|
guard OrientationManager.shared.noteGeometryUpdateFailure(error) else { return }
|
|
if appEnvironment?.settingsManager.inAppOrientationLock ?? false {
|
|
OrientationManager.shared.unlock()
|
|
}
|
|
togglePortraitDetailsPanel()
|
|
}
|
|
|
|
/// Fullscreen fallback when rotation isn't available: show/hide the
|
|
/// portrait details panel, mirroring `onToggleDetailsVisibility`.
|
|
private func togglePortraitDetailsPanel() {
|
|
hideControlsDuringTransition = true
|
|
if !isPortraitPanelVisible {
|
|
navigationCoordinator?.pinchPanscan = 0
|
|
}
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
isPortraitPanelVisible.toggle()
|
|
}
|
|
navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible
|
|
appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = isPortraitPanelVisible
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(350))
|
|
hideControlsDuringTransition = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Rotation Monitoring
|
|
|
|
/// Simplified rotation monitoring - handles layout transitions via accelerometer.
|
|
func setupRotationMonitoring() {
|
|
guard let appEnvironment else { return }
|
|
|
|
MPVLogging.log("setupRotationMonitoring: starting")
|
|
let rotationManager = DeviceRotationManager.shared
|
|
|
|
// Set up callback for landscape detection (request landscape rotation)
|
|
rotationManager.onLandscapeDetected = { [weak appEnvironment] in
|
|
Task { @MainActor in
|
|
guard let appEnvironment else { return }
|
|
guard !appEnvironment.settingsManager.inAppOrientationLock,
|
|
OrientationManager.shared.canRotateProgrammatically else { return }
|
|
|
|
let playerState = appEnvironment.playerService.state
|
|
|
|
// Only rotate if we have a video playing
|
|
guard playerState.currentVideo != nil,
|
|
playerState.pipState != .active else { return }
|
|
|
|
// Request landscape rotation
|
|
guard let windowScene = UIApplication.shared.connectedScenes
|
|
.compactMap({ $0 as? UIWindowScene })
|
|
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
|
|
|
let screenBounds = windowScene.screen.bounds
|
|
if screenBounds.height > screenBounds.width {
|
|
let targetOrientation = Self.currentLandscapeInterfaceOrientation()
|
|
MPVLogging.logTransition("onLandscapeDetected: requesting landscape",
|
|
fromSize: screenBounds.size, toSize: nil)
|
|
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: targetOrientation)) { _ in }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set up callback for portrait detection (request portrait rotation)
|
|
rotationManager.onPortraitDetected = { [weak appEnvironment] in
|
|
Task { @MainActor in
|
|
guard let appEnvironment else { return }
|
|
guard !appEnvironment.settingsManager.inAppOrientationLock,
|
|
OrientationManager.shared.canRotateProgrammatically else { return }
|
|
|
|
// Request portrait rotation
|
|
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 {
|
|
MPVLogging.logTransition("onPortraitDetected: requesting portrait",
|
|
fromSize: screenBounds.size, toSize: nil)
|
|
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait)) { _ in }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set up callback for landscape-to-landscape rotation (rotate between left and right)
|
|
rotationManager.onLandscapeOrientationChanged = { [weak appEnvironment] newOrientation in
|
|
Task { @MainActor in
|
|
guard let appEnvironment else { return }
|
|
guard !appEnvironment.settingsManager.inAppOrientationLock,
|
|
OrientationManager.shared.canRotateProgrammatically else { return }
|
|
|
|
guard let windowScene = UIApplication.shared.connectedScenes
|
|
.compactMap({ $0 as? UIWindowScene })
|
|
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
|
|
|
// Device and interface orientations are inverted
|
|
let targetOrientation: UIInterfaceOrientationMask = switch newOrientation {
|
|
case .landscapeLeft:
|
|
.landscapeRight
|
|
case .landscapeRight:
|
|
.landscapeLeft
|
|
default:
|
|
.landscape
|
|
}
|
|
|
|
MPVLogging.logTransition("onLandscapeOrientationChanged: \(newOrientation.rawValue)")
|
|
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: targetOrientation)) { _ in }
|
|
}
|
|
}
|
|
|
|
// Always start monitoring
|
|
rotationManager.startMonitoring()
|
|
}
|
|
|
|
/// Get the interface orientation mask matching the device's current landscape orientation.
|
|
static func currentLandscapeInterfaceOrientation() -> UIInterfaceOrientationMask {
|
|
let deviceOrientation = DeviceRotationManager.shared.detectedOrientation
|
|
// Device and interface orientations are inverted
|
|
switch deviceOrientation {
|
|
case .landscapeLeft:
|
|
return .landscapeRight
|
|
case .landscapeRight:
|
|
return .landscapeLeft
|
|
default:
|
|
return .landscape // Fallback to any landscape
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
#endif
|