Files
yattee/Yattee/Views/Player/ExpandedPlayerWindow.swift
Arkadiusz Fal e276c99d0b Allow pinch-to-panscan on the video in the corner layout
The window-level pinch was blocked whenever the landscape panel was
pinned and visible (the corner layout always counts as pinned) or a
stale portrait-panel flag was set. In the corner layout the pinch now
starts when it begins inside the video region, and the panscan
control button is enabled there too.
2026-10-20 23:43:47 +02:00

1412 lines
64 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
}
/// The player is a video-first, mostly non-scrolling UI: keep the status bar horizontal
/// (iPhone Duo exterior) instead of reserving a trailing column beside the video.
@available(iOS 27.1, *)
override var preferredVerticalBarBehavior: UIVerticalBarBehavior {
.disabled
}
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 true while the corner layout is active (its panels don't block pinch)
var isCornerLayoutActive: (() -> Bool)?
/// Returns the corner layout video region frame in screen coordinates (pinch must start inside it)
var getCornerVideoFrame: (() -> 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 }
// Corner layout: the video sits in its own region beside the
// panels, so pinching it is fine — but not over the panels
if isCornerLayoutActive?() == true {
let videoFrame = getCornerVideoFrame?() ?? .zero
return videoFrame.isEmpty || videoFrame.contains(gestureRecognizer.location(in: nil))
}
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.isCornerLayoutActive = { [weak self] in
self?.appEnvironment?.navigationCoordinator.isCornerLayoutActive ?? false
}
handler.getCornerVideoFrame = { [weak self] in
self?.appEnvironment?.navigationCoordinator.cornerVideoFrame ?? .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