Files
yattee/Yattee/Views/Player/ExpandedPlayerWindow.swift
2026-02-12 04:44:16 +01:00

1059 lines
47 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?
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<Content: View>: UIHostingController<Content> {
/// Controller to communicate status bar visibility from SwiftUI
let statusBarController: StatusBarVisibilityController
/// Callback when rotation transition occurs (viewWillTransition is called)
var onRotationTransition: (() -> Void)?
init(rootView: Content, statusBarController: StatusBarVisibilityController) {
self.statusBarController = statusBarController
super.init(rootView: rootView)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.allButUpsideDown
}
override var shouldAutorotate: Bool {
true
}
override var prefersStatusBarHidden: Bool {
statusBarController.isHidden
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
.fade
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
// Notify window manager that rotation is occurring
// This works for both system auto-rotate AND manual fullscreen toggle
onRotationTransition?()
// Update window frame to match new scene bounds after rotation or resize
// Use coordinateSpace.bounds for proper iPad Stage Manager / Split View support
coordinator.animate(alongsideTransition: { [weak self] _ in
guard let window = self?.view.window,
let scene = window.windowScene else { return }
window.frame = scene.coordinateSpace.bounds
})
}
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