Files
yattee/Yattee/Views/Player/ExpandedPlayerSheet+Orientation.swift
Arkadiusz Fal 5f31610489 Look up the foreground scene instead of the first connected one
The swipe-action share sheet, the expanded player's safe area and the
Dynamic Island side check took connectedScenes.first, which ignores
which scene is active (on the iPhone Duo it can be the other panel's)
and presented the share sheet from the main window, behind an expanded
player.

UIApplication.foregroundWindowScene/foregroundWindow resolve the
foreground-active scene (else foreground-inactive, else any) and its
key window, the order the widescreen layout already used. The share
sheet now presents from the key window, which is the player's while it
is up. The tvOS display mode manager uses the same lookup.
2026-10-04 23:27:18 +02:00

224 lines
9.7 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.foregroundWindow?.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