mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Pages list - Info is pinned first and always enabled in every layout: normalized pages force it to the front and enabled (older presets self-correct), disabling it is a no-op, and any move that would displace it snaps back. The iOS/macOS list renders it outside the reorderable ForEach (no drag handle, nothing drops above it) and the editors hide its enabled toggle; tvOS also hides its move buttons. The "at least one page must stay enabled" guard and its strings are gone since Info always counts. - iPhone never renders queue or comments as pages, so its Sections list hides them (PanelSettings.hiddenSettingsPageTypes). Reordering goes through moveVisiblePages, which keeps hidden entries in their stored slots. Comments get their own group on the Details Panel screen (pill mode + avatars) and the overlay hosts pass the avatars flag. Default visibility - "When Player Opens" is now configured per orientation (portraitDefaultVisibility / landscapeDefaultVisibility). The legacy single value is still decoded and seeds both until set individually; presets that never set it default to portrait Visible, landscape Hidden. macOS shows one picker (no portrait layout). - "Remember Last" no longer leaks across orientations: separate remembered flags, written by the portrait toggles and by the wide and corner layouts respectively, with the old shared flag read once as a fallback. The corner layout seeded from the portrait resolution before; it now uses landscape. Section options (iOS inline-sections hosts) - Chapters and Related editors gain "Expanded by Default" (on by default). The Info editor gains a Description group with "Expanded by Default" (off) and a "Collapsed Lines" stepper (1–10, default 3). Applied when the panel appears and on each video change. - Expanding a collapsed section scrolls its header to the top of the panel: PanelPageScrollView can hand its scroll proxy to page content and each inline section is wrapped in one anchored container. - The portrait panel's bottom fade sized its touch blocker from a stale "expanded comments pill" answer that ignored collapse on scroll, so a collapsed section header (the last item) sat under it and could not be tapped. The predicate now tracks the pill's actual collapse state.
1108 lines
50 KiB
Swift
1108 lines
50 KiB
Swift
//
|
|
// ExpandedPlayerWindow.swift
|
|
// Yattee
|
|
//
|
|
// Manages expanded player window on iOS.
|
|
// Uses a separate UIWindow above main content for reliable presentation/dismissal.
|
|
//
|
|
|
|
#if os(iOS)
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
// MARK: - Status Bar Visibility Controller
|
|
|
|
/// Observable controller for status bar visibility
|
|
@Observable
|
|
final class StatusBarVisibilityController {
|
|
var isHidden: Bool = false
|
|
weak var viewController: UIViewController?
|
|
/// Last non-zero status bar height seen — layouts that keep a fixed
|
|
/// status-bar-sized inset while the bar is hidden (corner layout) read
|
|
/// it because the safe area reports 0 once the bar is gone.
|
|
var knownStatusBarHeight: CGFloat = 0
|
|
|
|
func setHidden(_ hidden: Bool) {
|
|
rememberStatusBarHeight(
|
|
viewController?.view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
|
|
)
|
|
isHidden = hidden
|
|
viewController?.setNeedsStatusBarAppearanceUpdate()
|
|
}
|
|
|
|
func rememberStatusBarHeight(_ height: CGFloat) {
|
|
if height > 0 { knownStatusBarHeight = height }
|
|
}
|
|
}
|
|
|
|
/// Environment key for status bar controller
|
|
private struct StatusBarVisibilityControllerKey: EnvironmentKey {
|
|
static var defaultValue: StatusBarVisibilityController? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var statusBarVisibilityController: StatusBarVisibilityController? {
|
|
get { self[StatusBarVisibilityControllerKey.self] }
|
|
set { self[StatusBarVisibilityControllerKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
/// View modifier to hide status bar in ExpandedPlayerWindow
|
|
private struct StatusBarHiddenModifier: ViewModifier {
|
|
let hidden: Bool
|
|
@Environment(\.statusBarVisibilityController) private var controller
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onChange(of: hidden, initial: true) { _, newValue in
|
|
controller?.setHidden(newValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Hides the status bar when presented in ExpandedPlayerWindow
|
|
func playerStatusBarHidden(_ hidden: Bool = true) -> some View {
|
|
modifier(StatusBarHiddenModifier(hidden: hidden))
|
|
}
|
|
}
|
|
|
|
/// View controller for expanded player that supports all orientations
|
|
private final class ExpandedPlayerHostingController<Content: View>: UIHostingController<Content> {
|
|
/// Controller to communicate status bar visibility from SwiftUI
|
|
let statusBarController: StatusBarVisibilityController
|
|
|
|
/// Callback when rotation transition occurs (viewWillTransition is called)
|
|
var onRotationTransition: (() -> Void)?
|
|
|
|
init(rootView: Content, statusBarController: StatusBarVisibilityController) {
|
|
self.statusBarController = statusBarController
|
|
super.init(rootView: rootView)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
|
.allButUpsideDown
|
|
}
|
|
|
|
override var shouldAutorotate: Bool {
|
|
true
|
|
}
|
|
|
|
override var prefersStatusBarHidden: Bool {
|
|
statusBarController.isHidden
|
|
}
|
|
|
|
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
|
.fade
|
|
}
|
|
|
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
|
super.viewWillTransition(to: size, with: coordinator)
|
|
|
|
// Notify window manager that rotation is occurring
|
|
// This works for both system auto-rotate AND manual fullscreen toggle
|
|
onRotationTransition?()
|
|
|
|
// Update window frame to match new scene bounds after rotation or resize
|
|
// Use coordinateSpace.bounds for proper iPad Stage Manager / Split View support
|
|
coordinator.animate(alongsideTransition: { [weak self] _ in
|
|
guard let window = self?.view.window,
|
|
let scene = window.windowScene else { return }
|
|
window.frame = scene.coordinateSpace.bounds
|
|
})
|
|
}
|
|
|
|
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)?
|
|
/// 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
|
|
}
|
|
|
|
// 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?
|
|
|
|
// 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; 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
|
|
// 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.getCornerBelowPanelFrame = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.cornerBelowPanelFrame ?? .zero
|
|
}
|
|
handler.getPanelResizeGrabberFrame = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.panelResizeGrabberFrame ?? .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
|