Files
yattee/Yattee/Views/Player/ExpandedPlayerWindow.swift
Arkadiusz Fal 2d3ae1b0e5 Block the player dismiss pan from starting on side sliders
The window-level dismiss pan recognizes before SwiftUI's slider
DragGesture sets isAdjustingPlayerSliders, so dragging volume or
brightness often dismissed the player instead. Side sliders now publish
their frames and the pan refuses to begin from touches inside them.
2026-10-10 23:58:16 +02:00

1389 lines
63 KiB
Swift

//
// ExpandedPlayerWindow.swift
// Yattee
//
// Manages expanded player window on iOS.
// Uses a separate UIWindow above main content for reliable presentation/dismissal.
//
#if os(iOS)
import SwiftUI
import UIKit
// MARK: - Status Bar Visibility Controller
/// Observable controller for status bar visibility
@Observable
final class StatusBarVisibilityController {
var isHidden: Bool = false
weak var viewController: UIViewController?
/// Last non-zero status bar height seen — layouts that keep a fixed
/// status-bar-sized inset while the bar is hidden (corner layout) read
/// it because the safe area reports 0 once the bar is gone.
var knownStatusBarHeight: CGFloat = 0
func setHidden(_ hidden: Bool) {
rememberStatusBarHeight(
viewController?.view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
)
isHidden = hidden
viewController?.setNeedsStatusBarAppearanceUpdate()
}
func rememberStatusBarHeight(_ height: CGFloat) {
if height > 0 { knownStatusBarHeight = height }
}
}
/// Environment key for status bar controller
private struct StatusBarVisibilityControllerKey: EnvironmentKey {
static var defaultValue: StatusBarVisibilityController? = nil
}
extension EnvironmentValues {
var statusBarVisibilityController: StatusBarVisibilityController? {
get { self[StatusBarVisibilityControllerKey.self] }
set { self[StatusBarVisibilityControllerKey.self] = newValue }
}
}
/// View modifier to hide status bar in ExpandedPlayerWindow
private struct StatusBarHiddenModifier: ViewModifier {
let hidden: Bool
@Environment(\.statusBarVisibilityController) private var controller
func body(content: Content) -> some View {
content
.onChange(of: hidden, initial: true) { _, newValue in
controller?.setHidden(newValue)
}
}
}
extension View {
/// Hides the status bar when presented in ExpandedPlayerWindow
func playerStatusBarHidden(_ hidden: Bool = true) -> some View {
modifier(StatusBarHiddenModifier(hidden: hidden))
}
}
/// View controller for expanded player that supports all orientations
private final class ExpandedPlayerHostingController<Content: View>: UIHostingController<Content> {
/// Controller to communicate status bar visibility from SwiftUI
let statusBarController: StatusBarVisibilityController
/// Callback when rotation transition occurs (viewWillTransition is called)
var onRotationTransition: (() -> Void)?
init(rootView: Content, statusBarController: StatusBarVisibilityController) {
self.statusBarController = statusBarController
super.init(rootView: rootView)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.allButUpsideDown
}
override var shouldAutorotate: Bool {
true
}
override var prefersStatusBarHidden: Bool {
statusBarController.isHidden
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
.fade
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
// Notify window manager that rotation is occurring
// This works for both system auto-rotate AND manual fullscreen toggle
onRotationTransition?()
// Update window frame to match new scene bounds after rotation or resize
// Use coordinateSpace.bounds for proper iPad Stage Manager / Split View support
coordinator.animate(alongsideTransition: { [weak self] _ in
guard let window = self?.view.window,
let scene = window.windowScene else { return }
window.frame = scene.coordinateSpace.bounds
}, completion: { [weak self] _ in
// Folding/unfolding the iPhone Duo (and display handoffs in
// general) changes the safe area without a rotation; SwiftUI
// keeps serving the previous insets to the sheet's GeometryReader
// until the hosting controller's safe area is invalidated. Nudge
// additionalSafeAreaInsets, the same trick the dismiss path uses
// for the main window.
guard let self else { return }
let originalInsets = self.additionalSafeAreaInsets
self.additionalSafeAreaInsets = UIEdgeInsets(
top: originalInsets.top + 1,
left: originalInsets.left,
bottom: originalInsets.bottom,
right: originalInsets.right
)
self.additionalSafeAreaInsets = originalInsets
})
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Ensure window size stays in sync with scene bounds during iPad resize
// This handles Stage Manager / Split View live resize where viewWillTransition may not be called
// Only update SIZE, not origin - origin is managed by drag-to-dismiss gesture
guard let window = view.window,
let scene = window.windowScene else { return }
let sceneSize = scene.coordinateSpace.bounds.size
if window.frame.size != sceneSize {
window.frame.size = sceneSize
}
}
}
/// Handles drag-to-dismiss by tracking scroll view overscroll
private final class DragToDismissGestureHandler: NSObject, UIGestureRecognizerDelegate {
weak var window: UIWindow?
var onDismiss: (() -> Void)?
var onDismissGestureStateChanged: ((Bool) -> Void)?
var onHapticFeedback: (() -> Void)?
// Panscan callbacks
var onPanscanChanged: ((Double) -> Void)?
var onPinchGestureStateChanged: ((Bool) -> Void)?
var getCurrentPanscan: (() -> Double)?
/// Returns true if panscan should snap to fit/fill when released
var shouldSnapPanscan: (() -> Bool)?
/// Returns true if panel is both pinned AND visible on screen
var isPanelPinnedAndVisible: (() -> Bool)?
/// Returns true if expanded comments view is showing (blocks sheet dismiss)
var isCommentsExpanded: (() -> Bool)?
/// Returns true if user is adjusting volume/brightness sliders (blocks sheet dismiss)
var isAdjustingPlayerSliders: (() -> Bool)?
/// Returns true if user is dragging the portrait panel (blocks sheet dismiss)
var isPanelDragging: (() -> Bool)?
/// Returns the portrait panel frame in screen coordinates (for gesture conflict resolution)
var getPortraitPanelFrame: (() -> CGRect)?
/// Returns the corner layout below-video panel frame in screen coordinates (blocks dismiss pan)
var getCornerBelowPanelFrame: (() -> CGRect)?
/// Returns the panel resize grabber strip frame in screen coordinates (blocks dismiss pan)
var getPanelResizeGrabberFrame: (() -> CGRect)?
var getPlayerSideSliderFrames: (() -> [CGRect])?
/// Returns whether the portrait panel is currently visible (not hidden off-screen)
var isPortraitPanelVisible: (() -> Bool)?
/// Returns the progress bar frame in screen coordinates (for gesture conflict resolution)
var getProgressBarFrame: (() -> CGRect)?
/// Returns true if a seek gesture is currently active (blocks pinch gesture)
var isSeekGestureActive: (() -> Bool)?
/// Returns the comments overlay frame in screen coordinates (for gesture conflict resolution)
var getCommentsFrame: (() -> CGRect)?
// Main window scaling callbacks (Apple Music-style effect)
var onMainWindowScaleChanged: ((CGFloat) -> Void)?
var onMainWindowScaleAnimated: ((CGFloat) -> Void)? // Animated scale to progress
private let dismissThreshold: CGFloat = 100
private var isDismissing = false
private var scrollViewAtTop = false
private weak var trackedScrollView: UIScrollView?
private var scrollObservation: NSKeyValueObservation?
// Panscan gesture state
private var basePanscan: Double = 0.0
func trackScrollView(_ scrollView: UIScrollView) {
trackedScrollView = scrollView
scrollObservation = scrollView.observe(\.contentOffset, options: [.new]) { [weak self] scrollView, _ in
self?.scrollViewAtTop = scrollView.contentOffset.y <= 0
}
scrollViewAtTop = scrollView.contentOffset.y <= 0
}
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
guard let window = window else { return }
let translation = gesture.translation(in: window)
let velocity = gesture.velocity(in: window)
switch gesture.state {
case .began:
isDismissing = false
case .changed:
// If user added a second finger (pinch), cancel the pan gesture entirely
// so the SwiftUI MagnificationGesture can take over
if gesture.numberOfTouches >= 2 {
if isDismissing {
isDismissing = false
onDismissGestureStateChanged?(false)
UIView.animate(withDuration: 0.2) {
window.frame.origin.y = 0
// Restore main window scale when canceling drag
self.onMainWindowScaleChanged?(1.0)
}
}
// Cancel gesture by toggling enabled state - this releases the touches
gesture.isEnabled = false
gesture.isEnabled = true
return
}
// Only start dismissing if scroll view is at top (or no scroll view) and pulling down
let canDismiss = trackedScrollView == nil || scrollViewAtTop
if canDismiss && translation.y > 0 {
if !isDismissing {
isDismissing = true
onDismissGestureStateChanged?(true)
}
// Disable scroll view bouncing while dismissing
trackedScrollView?.bounces = false
// Move the window down with resistance
window.frame.origin.y = translation.y * 0.5
// Update main window scale based on drag progress
// As user drags down, main window scales back towards normal (1.0 -> 0.0 progress)
let dragProgress = min(translation.y / window.bounds.height, 1.0)
let scaleProgress = 1.0 - dragProgress // Invert: dragging down = scale back to normal
onMainWindowScaleChanged?(scaleProgress)
}
case .ended, .cancelled:
// Re-enable bouncing
trackedScrollView?.bounces = true
if isDismissing {
let shouldDismiss = translation.y > dismissThreshold || velocity.y > 800
if shouldDismiss {
// Trigger haptic immediately on release
onHapticFeedback?()
// The window manager owns the dismiss animation (morph into
// the mini player, or slide off screen) from the current position
onDismiss?()
} else {
// Snap back - restore main window scale to fully scaled
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0) {
window.frame.origin.y = 0
} completion: { [weak self] _ in
// Delay dismiss gesture state change until after snap-back animation completes
// This keeps the black overlay hidden so user can see the scale animation
self?.onDismissGestureStateChanged?(false)
}
onMainWindowScaleAnimated?(1.0)
}
}
isDismissing = false
default:
break
}
}
/// Pinch gesture handler - updates panscan value based on gesture scale
@objc func handlePinch(_ gesture: UIPinchGestureRecognizer) {
switch gesture.state {
case .began:
// Capture current panscan when gesture starts
basePanscan = getCurrentPanscan?() ?? 0.0
onPinchGestureStateChanged?(true)
case .changed:
// Calculate delta from gesture scale (1.0 = no change)
let rawDelta = (gesture.scale - 1.0) * 2.0
// Apply ease-out curve for bouncy feel
let easedDelta = rawDelta >= 0
? pow(rawDelta, 0.6)
: -pow(-rawDelta, 0.6)
// Calculate new panscan value, clamped to 0-1
let newPanscan = max(0, min(1, basePanscan + easedDelta))
onPanscanChanged?(newPanscan)
case .ended, .cancelled:
let currentPanscan = getCurrentPanscan?() ?? 0.0
// Check if we should snap to fit/fill or allow free zoom
if shouldSnapPanscan?() ?? true {
// Snap to 0 or 1
let targetPanscan: Double = currentPanscan > 0.5 ? 1.0 : 0.0
animatePanscan(from: currentPanscan, to: targetPanscan)
}
// If not snapping, leave the value exactly where it is
onPinchGestureStateChanged?(false)
default:
break
}
}
/// Animates panscan value from start to end with ease-out curve
private func animatePanscan(from start: Double, to end: Double) {
let duration: Double = 0.25
let steps = 15
let stepDuration = duration / Double(steps)
for step in 0...steps {
let progress = Double(step) / Double(steps)
// Ease-out curve for smooth deceleration
let easedProgress = 1 - pow(1 - progress, 3)
let value = start + (end - start) * easedProgress
DispatchQueue.main.asyncAfter(deadline: .now() + stepDuration * Double(step)) { [weak self] in
self?.onPanscanChanged?(value)
}
}
}
// Always begin - we'll check scroll position in handlePan
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
// Don't allow pinch gesture when pinned panel is visible on screen or seek gesture is active
if gestureRecognizer is UIPinchGestureRecognizer {
if isSeekGestureActive?() == true { return false }
if isPortraitPanelVisible?() == true { return false }
return !(isPanelPinnedAndVisible?() ?? false)
}
guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer else { return false }
// Don't begin if this looks like a pinch gesture (2+ fingers)
if panGesture.numberOfTouches >= 2 {
return false
}
// Don't begin dismiss gesture if a sheet/modal is presented on top
if let rootVC = window?.rootViewController,
rootVC.presentedViewController != nil {
return false
}
// When comments are expanded, only block dismiss if touch is within comments frame
if isCommentsExpanded?() == true {
let commentsFrame = getCommentsFrame?() ?? .zero
if !commentsFrame.isEmpty {
let touchLocation = panGesture.location(in: nil)
if commentsFrame.contains(touchLocation) {
return false
}
} else {
return false // No frame info — fall back to blanket blocking
}
}
// Don't begin dismiss gesture when adjusting volume/brightness sliders
if isAdjustingPlayerSliders?() == true {
return false
}
// ...or when the touch starts on a slider: the flag above is set by
// SwiftUI's DragGesture, which fires after this pan recognizes
let sliderTouch = panGesture.location(in: nil)
if (getPlayerSideSliderFrames?() ?? []).contains(where: { $0.contains(sliderTouch) }) {
return false
}
// Don't begin dismiss gesture when dragging portrait panel
if isPanelDragging?() == true {
return false
}
// Corner layout: don't begin dismiss gesture from inside the
// below-video panel — its drag handle hides the panel instead
let belowPanelFrame = getCornerBelowPanelFrame?() ?? .zero
if !belowPanelFrame.isEmpty {
let touchLocation = panGesture.location(in: nil)
if belowPanelFrame.contains(touchLocation) {
return false
}
}
// Don't begin dismiss gesture from the panel resize grabber strip —
// a diagonal resize drag would otherwise be cancelled mid-resize
let grabberFrame = getPanelResizeGrabberFrame?() ?? .zero
if !grabberFrame.isEmpty {
let touchLocation = panGesture.location(in: nil)
if grabberFrame.contains(touchLocation) {
return false
}
}
// When portrait panel is visible, only allow dismiss from player area (above panel)
// This prevents race conditions where isPanelDragging isn't set yet when gesture begins
let panelFrame = getPortraitPanelFrame?() ?? .zero
let panelVisible = isPortraitPanelVisible?() ?? true
if !panelFrame.isEmpty && panelVisible {
let touchLocation = panGesture.location(in: nil) // screen coordinates
// Block if touch is at or below the panel's top edge
// This ensures dismiss only works on the player area
if touchLocation.y >= panelFrame.minY {
return false
}
}
let velocity = panGesture.velocity(in: gestureRecognizer.view)
// Only for downward gestures
return velocity.y > 0 && velocity.y > abs(velocity.x)
}
// Allow simultaneous recognition with scroll views
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// Our pinch gesture should recognize simultaneously with everything
// This allows SwiftUI's MagnificationGesture to also receive the touches
if gestureRecognizer is UIPinchGestureRecognizer {
return true
}
// Pan gesture should not recognize simultaneously with pinch gestures
if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPinchGestureRecognizer {
return false
}
// Track the scroll view if we find one
if let scrollView = otherGestureRecognizer.view as? UIScrollView, trackedScrollView == nil {
trackScrollView(scrollView)
}
return true
}
// Block pan gesture from receiving touches on slider and progress bar controls
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
// Only filter for pan gestures (our dismiss gesture)
guard gestureRecognizer is UIPanGestureRecognizer else { return true }
// Check if touch is within progress bar frame (coordinate-based detection)
// This is needed because SwiftUI overlays are transparent to UIKit hit-testing
if let progressBarFrame = getProgressBarFrame?(), progressBarFrame != .zero {
let locationInWindow = touch.location(in: window)
// Convert window location to screen coordinates
if let window = window {
let locationInScreen = window.convert(locationInWindow, to: nil)
if progressBarFrame.contains(locationInScreen) {
return false
}
}
}
// Check if touch is on a slider (hit-test based detection)
let location = touch.location(in: window)
if let hitView = window?.hitTest(location, with: nil) {
// Walk up the view hierarchy to find if we're touching a slider
var view: UIView? = hitView
while let currentView = view {
// Check for UISlider (SwiftUI Slider wraps this on iOS)
if currentView is UISlider {
return false
}
// Check class name for SwiftUI slider hosting views
let className = String(describing: type(of: currentView))
if className.contains("Slider") {
return false
}
view = currentView.superview
}
}
return true
}
}
@MainActor
final class ExpandedPlayerWindowManager {
static let shared = ExpandedPlayerWindowManager()
private var expandedWindow: UIWindow?
private weak var appEnvironment: AppEnvironment?
private var dragHandler: DragToDismissGestureHandler?
/// Whether a hide is in flight (window still up, cleanup pending).
private var isHiding = false
/// Whether the morph has ended and the window waits for the mini player to
/// adopt the video view before hiding (any hide meanwhile must not animate).
private var isHandingOffToMiniPlayer = false
private var pendingHandOffHideCompletions: [() -> Void] = []
// MARK: - Window Scaling Effect (Apple Music-style)
/// How much to scale down the main window (0.1 = 10% smaller)
private let maxScaleProgress: CGFloat = 0.08
/// Corner radius when fully scaled
private let scaledCornerRadius: CGFloat = 50
/// Cached reference to the view we're scaling (first subview of main window)
/// We cache this to ensure we always reset the same view we scaled
private weak var scaledPresentingView: UIView?
/// Tracks whether orientation changed during this player session
/// When rotation occurs, we skip animated scale reset to avoid frame glitches
private var didRotateDuringSession = false
/// Tracks if scale was skipped during show() because app was inactive
/// When true, we need to apply scale once app becomes active
private var pendingScaleApplication = false
/// Pending show request when scene wasn't ready (for retry)
private var pendingShowRequest: (appEnvironment: AppEnvironment, animated: Bool)?
/// Retry count for show() when scene isn't ready (max 3)
private var showRetryCount = 0
private let maxShowRetries = 3
private var backgroundObserver: (any NSObjectProtocol)?
private var foregroundObserver: (any NSObjectProtocol)?
private init() {
setupBackgroundObservers()
}
private func setupBackgroundObservers() {
// Reset main window transform when app goes to background
// This prevents visual glitches in the app switcher
backgroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor [weak self] in
guard let self else { return }
// If player is expanded when going inactive (e.g., Control Center),
// mark that we went through a transition. This ensures we use immediate
// reset on dismiss instead of animated, avoiding transform glitches.
if self.expandedWindow != nil {
self.didRotateDuringSession = true
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] willResignActive: player expanded, marking didRotateDuringSession=true")
}
self.resetMainWindowImmediate()
}
}
// Re-apply main window transform when app comes back to foreground
// (if the expanded player is still showing)
// Also processes any pending show requests that failed due to scene not being ready
foregroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor [weak self] in
guard let self else { return }
// Check if we have a pending show request from when scene wasn't ready
if let pending = self.pendingShowRequest {
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] didBecomeActive: processing pending show request")
self.pendingShowRequest = nil
// Small delay to ensure scene is fully active
try? await Task.sleep(for: .milliseconds(50))
self.show(with: pending.appEnvironment, animated: pending.animated)
} else if self.expandedWindow != nil {
// Check if we need to apply scale that was deferred during show()
if self.pendingScaleApplication {
self.pendingScaleApplication = false
// Animate the scale application since we deferred it
UIView.animate(withDuration: 0.25) {
self.scaleMainWindow(progress: 1.0)
}
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] didBecomeActive: applied deferred scale (animated)")
} else if !self.didRotateDuringSession {
// Re-apply scale if no transition occurred (Control Center, etc.)
// If didRotateDuringSession is true, we already reset in willResignActive
// and should NOT re-apply the scale to avoid frame corruption on dismiss
self.scaleMainWindow(progress: 1.0)
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] didBecomeActive: re-applied scale")
} else {
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] didBecomeActive: skipping scale (didRotateDuringSession=true)")
}
} else {
// Player was closed - ensure transform is reset
self.resetMainWindowImmediate()
}
}
}
}
// MARK: - Main Window Scaling
/// The main app window (the one behind the expanded player)
private var mainWindow: UIWindow? {
// Try to find window from any available scene (not just foregroundActive)
// This handles Control Center being open (foregroundInactive) and other transitions
let allScenes: [UIWindowScene] = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let activeScene: UIWindowScene? = allScenes.first { $0.activationState == .foregroundActive }
let inactiveScene: UIWindowScene? = allScenes.first { $0.activationState == .foregroundInactive }
let windowScene: UIWindowScene? = activeScene ?? inactiveScene
guard let windowScene else {
LoggingService.shared.logPlayer("[WindowScale] mainWindow: no suitable window scene found")
return nil
}
// Find the main window (not our expanded player window)
let expandedWin = expandedWindow
let window = windowScene.windows.first { $0 !== expandedWin && $0.windowLevel == .normal }
if window == nil {
LoggingService.shared.logPlayer("[WindowScale] mainWindow: no main window found in scene (state: \(windowScene.activationState.rawValue))")
}
return window
}
/// Applies scale and corner radius to main window based on progress (0 = normal, 1 = fully scaled)
func scaleMainWindow(progress: CGFloat) {
// Capture the presenting view reference on first call
if scaledPresentingView == nil {
scaledPresentingView = mainWindow?.subviews.first
LoggingService.shared.logPlayer("[WindowScale] Captured presentingView: \(String(describing: scaledPresentingView)), bounds: \(scaledPresentingView?.bounds ?? .zero)")
}
guard let presentingView = scaledPresentingView else {
LoggingService.shared.logPlayer("[WindowScale] scaleMainWindow(\(progress)) - NO presentingView!")
return
}
// Clamp progress to 0-1 range
let clampedProgress = max(0, min(1, progress))
// Calculate actual scale progress (e.g., 0.08 means scale to 0.92)
let scaleAmount = clampedProgress * maxScaleProgress
let scale = 1 - scaleAmount
// Offset to keep the view centered vertically after scaling
let offsetY = (presentingView.bounds.height * scaleAmount) / 2
// Apply corner radius proportional to progress
presentingView.layer.cornerRadius = clampedProgress * scaledCornerRadius
presentingView.layer.masksToBounds = true
// Apply scale and translation transform
presentingView.transform = CGAffineTransform.identity
.scaledBy(x: scale, y: scale)
.translatedBy(x: 0, y: offsetY)
}
/// Animates main window scale to target progress with spring animation
func scaleMainWindowAnimated(to progress: CGFloat, duration: TimeInterval = 0.3, damping: CGFloat = 0.7) {
guard let presentingView = scaledPresentingView ?? mainWindow?.subviews.first else { return }
let clampedProgress = max(0, min(1, progress))
let scaleAmount = clampedProgress * maxScaleProgress
let scale = 1 - scaleAmount
let offsetY = (presentingView.bounds.height * scaleAmount) / 2
let cornerRadius = clampedProgress * scaledCornerRadius
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: []) {
presentingView.transform = CGAffineTransform.identity
.scaledBy(x: scale, y: scale)
.translatedBy(x: 0, y: offsetY)
presentingView.layer.cornerRadius = cornerRadius
}
}
/// Resets main window to normal state immediately
func resetMainWindowImmediate() {
// Reset ALL subviews of the main window to handle rotation edge cases
// During rotation, UIKit may replace/rearrange transition views
guard let window = mainWindow else {
scaledPresentingView = nil
return
}
for subview in window.subviews {
// Remove any in-flight animations
subview.layer.removeAllAnimations()
subview.layer.cornerRadius = 0
subview.transform = .identity
// Reset frame to match window bounds (fix any rotation-induced frame issues)
subview.frame = window.bounds
// Force layout to apply changes immediately
subview.setNeedsLayout()
subview.layoutIfNeeded()
// Reset clipping
subview.clipsToBounds = false
subview.layer.masksToBounds = false
}
// Force the window itself to layout
window.setNeedsLayout()
window.layoutIfNeeded()
// Clear the cached reference
scaledPresentingView = nil
}
/// Called when rotation occurs during the player session (via viewWillTransition)
/// This works for both system auto-rotate AND manual fullscreen toggle
private func handleRotationDuringSession() {
guard expandedWindow != nil else { return }
// Mark that rotation is in progress - this temporarily disables scale effects
didRotateDuringSession = true
// Immediately reset main window transforms to prevent frame corruption
resetMainWindowImmediate()
LoggingService.shared.logPlayer("[WindowScale] handleRotationDuringSession: rotation detected, reset transforms")
// After rotation animation completes, re-apply scale effect so drag-to-dismiss works
// Use a delay to let the system rotation animation settle
Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(400))
guard let self, self.expandedWindow != nil else { return }
// Re-apply scale effect with animation
self.didRotateDuringSession = false
UIView.animate(withDuration: 0.25) {
self.scaleMainWindow(progress: 1.0)
}
LoggingService.shared.logPlayer("[WindowScale] handleRotationDuringSession: rotation settled, re-applied scale")
}
}
func show(with appEnvironment: AppEnvironment, animated: Bool = true) {
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show called, expandedWindow=\(expandedWindow != nil), animated=\(animated), retryCount=\(showRetryCount)")
guard expandedWindow == nil else { return }
self.appEnvironment = appEnvironment
// Get the active window scene - allow foregroundInactive for Control Center scenarios
let allScenes: [UIWindowScene] = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let activeScene: UIWindowScene? = allScenes.first { $0.activationState == .foregroundActive }
let inactiveScene: UIWindowScene? = allScenes.first { $0.activationState == .foregroundInactive }
let windowScene: UIWindowScene? = activeScene ?? inactiveScene
guard let windowScene else {
// Scene not ready - retry if we haven't exceeded max retries
if showRetryCount < maxShowRetries {
showRetryCount += 1
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show: no suitable window scene, scheduling retry \(showRetryCount)/\(maxShowRetries)")
pendingShowRequest = (appEnvironment, animated)
Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(100))
guard let self, let pending = self.pendingShowRequest else { return }
self.pendingShowRequest = nil
self.show(with: pending.appEnvironment, animated: pending.animated)
}
} else {
// Exceeded max retries - give up and reset state
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show: FAILED after \(maxShowRetries) retries, resetting state")
showRetryCount = 0
pendingShowRequest = nil
appEnvironment.navigationCoordinator.isPlayerExpanded = false
}
return
}
// Success - clear retry state
showRetryCount = 0
pendingShowRequest = nil
// Create a new window at a level above normal content but below alerts
let window = UIWindow(windowScene: windowScene)
window.windowLevel = .normal + 1 // Above main content, below fullscreen player
window.backgroundColor = .clear // Clear for dismiss gesture; SwiftUI provides background
// Create status bar controller; seed the bar height while the main
// UI still shows it (the player may hide it before any layout reads it)
let statusBarController = StatusBarVisibilityController()
statusBarController.rememberStatusBarHeight(windowScene.statusBarManager?.statusBarFrame.height ?? 0)
// Create the expanded player view with status bar controller in environment
// Seed the portrait panel state before the first frame so the preset's
// default visibility doesn't animate a video position jump on expand.
let initialPanelVisible = ExpandedPlayerSheet.initialPortraitPanelVisibility(
settingsManager: appEnvironment.settingsManager
)
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show: initialPortraitPanelVisible=\(initialPanelVisible), portraitDefaultVisibility=\(PanelSettings.cached.effectiveDefaultVisibility(for: .portrait))")
let expandedView = ExpandedPlayerSheet(initialPortraitPanelVisible: initialPanelVisible)
.appEnvironment(appEnvironment)
.environment(\.statusBarVisibilityController, statusBarController)
let hostingController = ExpandedPlayerHostingController(
rootView: expandedView,
statusBarController: statusBarController
)
// Add safe area insets for tab bar to help PiP avoid covering it
// Using 50pt as conservative value (inline/minimized tab bar height)
hostingController.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: 46, right: 0)
hostingController.view.backgroundColor = .clear
// Ensure the hosting controller's view resizes with the window during rotation
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
// Handle rotation transitions - reset main window transforms immediately
// This works for both system auto-rotate AND manual fullscreen toggle
hostingController.onRotationTransition = { [weak self] in
self?.handleRotationDuringSession()
}
// Set viewController reference so controller can trigger updates
statusBarController.viewController = hostingController
window.rootViewController = hostingController
// Add drag-to-dismiss gesture to the window
let handler = DragToDismissGestureHandler()
handler.window = window
handler.onHapticFeedback = { [weak self] in
self?.appEnvironment?.settingsManager.triggerHapticFeedback(for: .playerDismiss)
}
handler.onDismiss = { [weak self] in
self?.dismissFromGesture()
}
handler.onDismissGestureStateChanged = { [weak self] isActive in
self?.appEnvironment?.navigationCoordinator.isPlayerDismissGestureActive = isActive
}
// Panscan callbacks - connect UIKit pinch gesture to NavigationCoordinator
handler.onPanscanChanged = { [weak self] panscan in
self?.appEnvironment?.navigationCoordinator.pinchPanscan = panscan
}
handler.onPinchGestureStateChanged = { [weak self] isActive in
self?.appEnvironment?.navigationCoordinator.isPinchGestureActive = isActive
}
handler.getCurrentPanscan = { [weak self] in
self?.appEnvironment?.navigationCoordinator.pinchPanscan ?? 0.0
}
handler.shouldSnapPanscan = { [weak self] in
self?.appEnvironment?.navigationCoordinator.shouldSnapPanscan ?? true
}
handler.isPanelPinnedAndVisible = { [weak self] in
guard let settings = self?.appEnvironment?.settingsManager else { return false }
return settings.landscapeDetailsPanelPinned && settings.landscapeDetailsPanelVisible
}
handler.isCommentsExpanded = { [weak self] in
self?.appEnvironment?.navigationCoordinator.isCommentsExpanded ?? false
}
handler.isAdjustingPlayerSliders = { [weak self] in
self?.appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders ?? false
}
handler.isPanelDragging = { [weak self] in
self?.appEnvironment?.navigationCoordinator.isPanelDragging ?? false
}
handler.getPortraitPanelFrame = { [weak self] in
self?.appEnvironment?.navigationCoordinator.portraitPanelFrame ?? .zero
}
handler.getCornerBelowPanelFrame = { [weak self] in
self?.appEnvironment?.navigationCoordinator.cornerBelowPanelFrame ?? .zero
}
handler.getPanelResizeGrabberFrame = { [weak self] in
self?.appEnvironment?.navigationCoordinator.panelResizeGrabberFrame ?? .zero
}
handler.getPlayerSideSliderFrames = { [weak self] in
self?.appEnvironment?.navigationCoordinator.playerSideSliderFrames.values.filter { !$0.isEmpty } ?? []
}
handler.isPortraitPanelVisible = { [weak self] in
self?.appEnvironment?.navigationCoordinator.isPortraitPanelVisible ?? true
}
handler.getProgressBarFrame = { [weak self] in
self?.appEnvironment?.navigationCoordinator.progressBarFrame ?? .zero
}
handler.isSeekGestureActive = { [weak self] in
self?.appEnvironment?.navigationCoordinator.isSeekGestureActive ?? false
}
handler.getCommentsFrame = { [weak self] in
self?.appEnvironment?.navigationCoordinator.commentsFrame ?? .zero
}
// Main window scaling callbacks for interactive drag
handler.onMainWindowScaleChanged = { [weak self] progress in
guard let self else { return }
// Skip scaling if a transition occurred (Control Center, etc.)
// to prevent re-applying transforms after we've reset them
guard !self.didRotateDuringSession else { return }
self.scaleMainWindow(progress: progress)
}
// Animated scale callback for snap-back
handler.onMainWindowScaleAnimated = { [weak self] progress in
guard let self else { return }
guard !self.didRotateDuringSession else { return }
self.scaleMainWindowAnimated(to: progress)
}
self.dragHandler = handler
let panGesture = UIPanGestureRecognizer(target: handler, action: #selector(DragToDismissGestureHandler.handlePan(_:)))
panGesture.delegate = handler
// Add a pinch gesture recognizer for panscan
let pinchGesture = UIPinchGestureRecognizer(target: handler, action: #selector(DragToDismissGestureHandler.handlePinch(_:)))
pinchGesture.delegate = handler
window.addGestureRecognizer(pinchGesture)
window.addGestureRecognizer(panGesture)
// Set frame to match scene bounds (supports iPad Stage Manager / Split View)
window.frame = windowScene.coordinateSpace.bounds
window.makeKeyAndVisible()
self.expandedWindow = window
// Check if app is active BEFORE animation - if not active (e.g., Control Center open),
// we skip the scale effect and apply it later when app becomes active
let isAppActive = UIApplication.shared.applicationState == .active
pendingScaleApplication = !isAppActive
if !isAppActive {
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show: app not active, deferring scale effect")
}
if animated {
// Mark that sheet is animating to defer aspect ratio animation
appEnvironment.navigationCoordinator.isPlayerSheetAnimating = true
appEnvironment.navigationCoordinator.isPlayerExpanding = true
// Start off-screen using transform (more reliable than frame animation)
let sceneHeight = windowScene.coordinateSpace.bounds.height
hostingController.view.transform = CGAffineTransform(translationX: 0, y: sceneHeight)
// Force layout before animating
hostingController.view.layoutIfNeeded()
// Animate up - fast and smooth without bounce
// Also animate main window scaling down (Apple Music-style effect) - but only if app is active
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0, options: []) {
hostingController.view.transform = .identity
if isAppActive {
self.scaleMainWindow(progress: 1.0)
}
} completion: { _ in
// Sheet animation complete
appEnvironment.navigationCoordinator.isPlayerSheetAnimating = false
appEnvironment.navigationCoordinator.isPlayerExpanding = false
appEnvironment.navigationCoordinator.isPlayerWindowVisible = true
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show animation complete, isPlayerWindowVisible=true")
}
} else {
// No animation - apply scale immediately (only if app is active)
if isAppActive {
scaleMainWindow(progress: 1.0)
}
appEnvironment.navigationCoordinator.isPlayerExpanding = false
appEnvironment.navigationCoordinator.isPlayerWindowVisible = true
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show complete (no animation), isPlayerWindowVisible=true")
}
// Notify player service
appEnvironment.playerService.playerSheetDidAppear()
// Trigger haptic feedback
if animated {
appEnvironment.settingsManager.triggerHapticFeedback(for: .playerShow)
}
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show complete")
}
/// Drag-to-dismiss released past the threshold: morph the video into the
/// mini player when its preview is on screen, otherwise slide off screen.
private func dismissFromGesture() {
guard let nav = appEnvironment?.navigationCoordinator else { return }
if morphIntoMiniPlayer(completion: { [weak self] in
self?.handOffToMiniPlayer()
}) {
return
}
// Slide off: hide() animates from the dragged position and marks the
// window not visible before the gesture flag clears (no black overlay
// frame). isPlayerExpanded flips last, so the mini player doesn't take
// the video view from the sliding window.
expandedWindow?.isUserInteractionEnabled = false
hide(animated: true) {
nav.isPlayerExpanded = false
}
nav.isPlayerDismissGestureActive = false
}
/// Ends the morph: un-expands so the mini player mounts its video view, and
/// hides the window only once that view has actually moved into the mini
/// player. SwiftUI may mount it a frame later; hiding first left a frame
/// with neither (a blank preview).
private func handOffToMiniPlayer() {
guard let nav = appEnvironment?.navigationCoordinator else { return }
let playerView = (appEnvironment?.playerService.currentBackend as? MPVBackend)?.playerView
nonisolated(unsafe) var observer: (any NSObjectProtocol)?
let finish: () -> Void = { [weak self] in
if let observer {
NotificationCenter.default.removeObserver(observer)
}
observer = nil
nav.isPlayerMorphingToMini = false
guard let self, self.isHandingOffToMiniPlayer else { return }
self.isHandingOffToMiniPlayer = false
// Hide now, in the same pass the mini player adopts the view; the full
// cleanup (layout passes, orientation) runs outside that SwiftUI update
self.expandedWindow?.isHidden = true
let completions = self.pendingHandOffHideCompletions
self.pendingHandOffHideCompletions = []
DispatchQueue.main.async { [weak self] in
self?.hide(animated: false) {
completions.forEach { $0() }
}
}
}
isHandingOffToMiniPlayer = true
if let playerView, playerView.window === expandedWindow {
// Adopting isn't enough: the view keeps its old size and the new
// container is still zero-sized until the next layout pass, so the
// preview is blank for a frame. Wait until it's laid out there.
observer = NotificationCenter.default.addObserver(
forName: .mpvPlayerViewDidChangeContainer, object: playerView, queue: nil
) { [weak self] _ in
MainActor.assumeIsolated {
if let observer {
NotificationCenter.default.removeObserver(observer)
}
observer = nil
self?.waitUntilLaidOutInMiniPlayer(playerView, then: finish)
}
}
// Fallback when the mini player never takes the view (video hidden in it)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { finish() }
} else {
DispatchQueue.main.async { finish() }
}
nav.isPlayerDismissGestureActive = false
nav.isPlayerCollapsing = true
nav.isPlayerExpanded = false
}
/// Calls `then` on the first display frame after the player view sits,
/// sized, in a container outside the expanded window: by then that layout
/// pass has re-rendered it into its resized framebuffer.
private func waitUntilLaidOutInMiniPlayer(_ playerView: UIView, then: @escaping () -> Void) {
let isLaidOut = { [weak self] in
let size = playerView.bounds.size
return playerView.window != nil
&& playerView.window !== self?.expandedWindow
&& size.width > 1 && size.height > 1
&& playerView.superview?.bounds.size == size
}
// The container adopts it already sized and redrawn: hide right away,
// in the same frame (the window has a black hole where the video was)
if isLaidOut() {
then()
return
}
var wasLaidOut = false
let ticker = DisplayLinkTicker()
ticker.start { [weak self] in
guard let self, self.isHandingOffToMiniPlayer else {
ticker.stop()
return
}
if wasLaidOut {
ticker.stop()
then()
}
wasLaidOut = isLaidOut()
}
}
/// Makes the main window's SwiftUI content pick up its current safe area
/// (setNeedsLayout alone doesn't invalidate SwiftUI's cached insets).
private func refreshMainWindowSafeArea() {
guard let rootVC = mainWindow?.rootViewController else { return }
let insets = rootVC.additionalSafeAreaInsets
rootVC.additionalSafeAreaInsets = UIEdgeInsets(top: insets.top + 1, left: insets.left, bottom: insets.bottom, right: insets.right)
rootVC.additionalSafeAreaInsets = insets
rootVC.view.setNeedsLayout()
rootVC.view.layoutIfNeeded()
}
/// Shrinks the expanded window onto its video and flies it into the mini
/// player's video preview. Returns false (doing nothing) when no target is
/// available, so the caller falls back to sliding off screen.
private func morphIntoMiniPlayer(completion: @escaping () -> Void) -> Bool {
guard let window = expandedWindow,
let hostView = window.rootViewController?.view,
let nav = appEnvironment?.navigationCoordinator,
!didRotateDuringSession,
let playerView = (appEnvironment?.playerService.currentBackend as? MPVBackend)?.playerView,
playerView.window === window,
mainWindow.map({ $0.bounds.size == window.bounds.size }) == true else {
return false
}
// The mini player reports its SwiftUI global frame, which ignores the scale
// transform on the main window's content (that animates back to identity
// alongside the morph), so it is already where the mini player ends up.
// The scaled content did get a smaller bottom safe area (UIKit derives it
// from the transformed frame), which comes back once unscaled and lifts
// the bottom-anchored mini player by the difference.
var target = nav.miniPlayerVideoFrame
if let mainWindow, let mainRootView = mainWindow.rootViewController?.view {
target.origin.y -= max(0, mainWindow.safeAreaInsets.bottom - mainRootView.safeAreaInsets.bottom)
}
let renderRect = playerView.convert(playerView.bounds, to: window)
let source = visibleVideoRect(in: renderRect, panelFrame: hostView.convert(nav.portraitPanelFrame, to: window), nav: nav)
guard target.width > 1, target.height > 1, window.bounds.contains(target),
source.width > 1, source.height > 1 else {
return false
}
// The mask doesn't stop hit testing: keep stray touches off the hidden controls
window.isUserInteractionEnabled = false
hostView.layer.removeAllAnimations()
nav.isPlayerMorphingToMini = true
nav.isPlayerWindowVisible = false
// Only the video morphs: the window mask keeps the video opaque as it
// flies to the target and fades everything around it (title, panels) out.
// The window keeps its drag offset and animates back to 0 alongside the
// morph; content and mask share that offset, so both are expressed in
// un-offset window coordinates. (Transforming the UIWindow itself stops
// it rendering.)
let mask = UIView(frame: window.bounds)
let surroundingMask = UIView(frame: window.bounds)
surroundingMask.backgroundColor = .black
let videoMask = UIView(frame: source)
videoMask.backgroundColor = .black
videoMask.layer.cornerCurve = .continuous
mask.addSubview(surroundingMask)
mask.addSubview(videoMask)
window.mask = mask
// Map the source rect onto the target rect (a view transform applies about its center)
let center = hostView.center
let scaleX = target.width / source.width
let scaleY = target.height / source.height
let finalTransform = CGAffineTransform(
a: scaleX, b: 0, c: 0, d: scaleY,
tx: target.midX - center.x - scaleX * (source.midX - center.x),
ty: target.midY - center.y - scaleY * (source.midY - center.y)
)
// UIKit derives a view's safe area from its transformed frame: once scaled
// toward the mini player the host no longer overlaps the status bar, its
// top inset drops to 0 and SwiftUI re-lays the player out higher — out
// from under the mask. Capture the insets to pin them below.
let insetsBefore = hostView.safeAreaInsets
UIView.animate(withDuration: 0.15) {
surroundingMask.alpha = 0
}
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0, options: []) {
window.frame.origin.y = 0
hostView.transform = finalTransform
videoMask.frame = target
videoMask.layer.cornerRadius = nav.miniPlayerVideoCornerRadius
self.scaleMainWindow(progress: 0)
} completion: { _ in
completion()
}
// Unscaled, the main content gets its full safe area back: re-lay it out
// now, under the morphing video, rather than with a late jump at the end
refreshMainWindowSafeArea()
// Pin the safe area: add back whatever the transform took away
if let hostingController = window.rootViewController {
window.layoutIfNeeded()
let insetsAfter = hostView.safeAreaInsets
let additional = hostingController.additionalSafeAreaInsets
hostingController.additionalSafeAreaInsets = UIEdgeInsets(
top: additional.top + insetsBefore.top - insetsAfter.top,
left: additional.left + insetsBefore.left - insetsAfter.left,
bottom: additional.bottom + insetsBefore.bottom - insetsAfter.bottom,
right: additional.right + insetsBefore.right - insetsAfter.right
)
hostView.layoutIfNeeded()
}
return true
}
/// The part of the render view that shows video: the aspect-fit picture,
/// minus whatever the portrait details panel covers.
private func visibleVideoRect(in renderRect: CGRect, panelFrame panel: CGRect, nav: NavigationCoordinator) -> CGRect {
let aspect = CGFloat(appEnvironment?.playerService.state.displayAspectRatio ?? 16.0 / 9.0)
var rect = renderRect
if aspect > 0 {
if renderRect.width / renderRect.height > aspect {
let width = renderRect.height * aspect
rect = CGRect(x: renderRect.midX - width / 2, y: renderRect.minY, width: width, height: renderRect.height)
} else {
let height = renderRect.width / aspect
rect = CGRect(x: renderRect.minX, y: renderRect.midY - height / 2, width: renderRect.width, height: height)
}
}
if nav.isPortraitPanelVisible, panel.height > 0, panel.minY < rect.maxY, panel.minY > rect.minY {
rect.size.height = panel.minY - rect.minY
}
return rect
}
func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide called, expandedWindow=\(expandedWindow != nil), animated=\(animated)")
// Clear any pending show request and scale application
pendingShowRequest = nil
showRetryCount = 0
pendingScaleApplication = false
appEnvironment?.navigationCoordinator.isPlayerWindowVisible = false
guard let window = expandedWindow else {
completion?()
return
}
// The morphed window sits on the mini player until the mini player has
// laid out the video; the handoff hides it then
if isHandingOffToMiniPlayer {
if let completion {
pendingHandOffHideCompletions.append(completion)
}
return
}
// A hide is already running (e.g. the gesture's hide followed by the
// isPlayerExpanded onChange); a second one would clean up twice
guard !isHiding else {
completion?()
return
}
isHiding = true
// Start collapsing animation
appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide: isPlayerWindowVisible=false, isPlayerCollapsing=true")
let cleanup: () -> Void = { [weak self] in
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide: cleanup")
self?.isHiding = false
// Always ensure main window transform is fully reset (safeguard against race conditions)
self?.resetMainWindowImmediate()
// Reset rotation tracking
self?.didRotateDuringSession = false
// Reset collapsing state
self?.appEnvironment?.navigationCoordinator.isPlayerCollapsing = false
self?.dragHandler = nil
self?.expandedWindow?.isHidden = true
self?.expandedWindow?.rootViewController = nil
self?.expandedWindow = nil
// Notify player service
self?.appEnvironment?.playerService.playerSheetDidDisappear()
// Tell main window to re-check orientation support and rotate if needed
if let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) {
// Update all windows' root view controllers
for window in windowScene.windows {
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
}
// Force SwiftUI safe area recalculation by toggling additionalSafeAreaInsets
// setNeedsStatusBarAppearanceUpdate() alone doesn't invalidate SwiftUI's safe area cache
for window in windowScene.windows where window != self?.expandedWindow {
if let rootVC = window.rootViewController {
// Toggle additionalSafeAreaInsets to force SwiftUI geometry recalculation
let originalInsets = rootVC.additionalSafeAreaInsets
rootVC.additionalSafeAreaInsets = UIEdgeInsets(
top: originalInsets.top + 1,
left: originalInsets.left,
bottom: originalInsets.bottom,
right: originalInsets.right
)
rootVC.additionalSafeAreaInsets = originalInsets
// Also trigger layout invalidation
rootVC.setNeedsStatusBarAppearanceUpdate()
rootVC.view.setNeedsLayout()
rootVC.view.layoutIfNeeded()
}
}
// If device is in portrait OR preferPortraitBrowsing is enabled, request portrait rotation
let screenBounds = windowScene.screen.bounds
let isScreenLandscape = screenBounds.width > screenBounds.height
let isDevicePortrait = DeviceRotationManager.shared.isPortrait
let preferPortrait = self?.appEnvironment?.settingsManager.preferPortraitBrowsing ?? false
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] Dismiss rotation check: isScreenLandscape=\(isScreenLandscape), isDevicePortrait=\(isDevicePortrait), preferPortrait=\(preferPortrait)")
if isScreenLandscape && (isDevicePortrait || preferPortrait) {
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] Requesting portrait rotation on dismiss")
// Lock to portrait to force rotation, then unlock after a short delay
OrientationManager.shared.lock(to: .portrait)
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))
// Unlock after rotation completes so user can rotate freely again
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
OrientationManager.shared.unlock()
}
}
}
completion?()
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide complete")
}
if animated {
// If rotation occurred during this session, skip the animated scale reset
// because the main window's UITransitionView has a corrupted frame after rotation
let shouldAnimateMainWindow = !didRotateDuringSession
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide: animated=\(animated), didRotateDuringSession=\(didRotateDuringSession), shouldAnimateMainWindow=\(shouldAnimateMainWindow)")
// Animate down - fast and smooth without bounce
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0, options: [], animations: {
window.frame.origin.y = window.bounds.height
if shouldAnimateMainWindow {
// Only animate scale if no rotation occurred
self.scaleMainWindow(progress: 0)
}
}, completion: { _ in
cleanup()
})
} else {
resetMainWindowImmediate()
cleanup()
}
}
}
#endif
#if os(iOS)
/// Runs a closure once per display frame until stopped.
@MainActor
private final class DisplayLinkTicker: NSObject {
private var displayLink: CADisplayLink?
private var tick: (() -> Void)?
func start(_ tick: @escaping () -> Void) {
self.tick = tick
let link = CADisplayLink(target: self, selector: #selector(fire))
link.add(to: .main, forMode: .common)
displayLink = link
}
func stop() {
displayLink?.invalidate()
displayLink = nil
tick = nil
}
@objc private func fire() {
tick?()
}
}
#endif