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