// // 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? func setHidden(_ hidden: Bool) { isHidden = hidden viewController?.setNeedsStatusBarAppearanceUpdate() } } /// 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: UIHostingController { /// 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 }) } 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 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 var onMainWindowScaleReset: ((Bool) -> Void)? // Bool = animated private let dismissThreshold: CGFloat = 100 private var isDismissing = false private var scrollViewAtTop = false private weak var trackedScrollView: UIScrollView? private var scrollObservation: NSKeyValueObservation? private var originalBackgroundColor: UIColor? // 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?() // Animate off screen then dismiss - fast and smooth without bounce // Main window scale reset is handled by hide() which is called via onDismiss UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0, options: []) { window.frame.origin.y = window.bounds.height // Continue scaling main window to fully normal during dismiss animation self.onMainWindowScaleChanged?(0) } completion: { [weak self] _ in self?.onDismissGestureStateChanged?(false) self?.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 } // Don't begin dismiss gesture when dragging portrait panel if isPanelDragging?() == true { 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? // 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 } /// The view to apply transforms to (first subview of main window) /// Note: For actual transformations, use scaledPresentingView which caches the reference private var presentingView: UIView? { mainWindow?.subviews.first } /// Current window height for calculating transforms private var windowHeight: CGFloat { mainWindow?.bounds.height ?? UIScreen.main.bounds.height } /// 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 = (windowHeight * 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 = (windowHeight * 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 with animation func resetMainWindowAnimated(duration: TimeInterval = 0.3) { let view = scaledPresentingView ?? mainWindow?.subviews.first UIView.animate(withDuration: duration) { view?.layer.cornerRadius = 0 view?.transform = .identity } completion: { [weak self] _ in view?.layer.masksToBounds = false self?.scaledPresentingView = nil } } /// 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") } } var isPresented: Bool { expandedWindow != nil } 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 let statusBarController = StatusBarVisibilityController() // Create the expanded player view with status bar controller in environment let expandedView = ExpandedPlayerSheet() .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 // Set collapsing state BEFORE isPlayerExpanded=false to ensure mini player // shows video immediately when it appears (it checks isPlayerCollapsing) self?.appEnvironment?.navigationCoordinator.isPlayerCollapsing = true self?.appEnvironment?.navigationCoordinator.isPlayerExpanded = false } 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.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") } 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 // Mark window as not visible immediately and start collapsing animation appEnvironment?.navigationCoordinator.isPlayerWindowVisible = false appEnvironment?.navigationCoordinator.isPlayerCollapsing = true LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide: isPlayerWindowVisible=false, isPlayerCollapsing=true") guard let window = expandedWindow else { completion?() return } let cleanup: () -> Void = { [weak self] in LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] hide: cleanup") // 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