mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
1048 lines
47 KiB
Swift
1048 lines
47 KiB
Swift
//
|
|
// ExpandedPlayerWindow.swift
|
|
// Yattee
|
|
//
|
|
// Manages expanded player window on iOS.
|
|
// Uses a separate UIWindow above main content for reliable presentation/dismissal.
|
|
//
|
|
|
|
#if os(iOS)
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
// MARK: - Status Bar Visibility Controller
|
|
|
|
/// Observable controller for status bar visibility
|
|
@Observable
|
|
final class StatusBarVisibilityController {
|
|
var isHidden: Bool = false
|
|
weak var viewController: UIViewController?
|
|
|
|
func setHidden(_ hidden: Bool) {
|
|
isHidden = hidden
|
|
viewController?.setNeedsStatusBarAppearanceUpdate()
|
|
}
|
|
}
|
|
|
|
/// Environment key for status bar controller
|
|
private struct StatusBarVisibilityControllerKey: EnvironmentKey {
|
|
static var defaultValue: StatusBarVisibilityController? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var statusBarVisibilityController: StatusBarVisibilityController? {
|
|
get { self[StatusBarVisibilityControllerKey.self] }
|
|
set { self[StatusBarVisibilityControllerKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
/// View modifier to hide status bar in ExpandedPlayerWindow
|
|
private struct StatusBarHiddenModifier: ViewModifier {
|
|
let hidden: Bool
|
|
@Environment(\.statusBarVisibilityController) private var controller
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onChange(of: hidden, initial: true) { _, newValue in
|
|
controller?.setHidden(newValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Hides the status bar when presented in ExpandedPlayerWindow
|
|
func playerStatusBarHidden(_ hidden: Bool = true) -> some View {
|
|
modifier(StatusBarHiddenModifier(hidden: hidden))
|
|
}
|
|
}
|
|
|
|
/// View controller for expanded player that supports all orientations
|
|
private final class ExpandedPlayerHostingController<Content: View>: UIHostingController<Content> {
|
|
/// Controller to communicate status bar visibility from SwiftUI
|
|
let statusBarController: StatusBarVisibilityController
|
|
|
|
/// Callback when rotation transition occurs (viewWillTransition is called)
|
|
var onRotationTransition: (() -> Void)?
|
|
|
|
init(rootView: Content, statusBarController: StatusBarVisibilityController) {
|
|
self.statusBarController = statusBarController
|
|
super.init(rootView: rootView)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
|
.allButUpsideDown
|
|
}
|
|
|
|
override var shouldAutorotate: Bool {
|
|
true
|
|
}
|
|
|
|
override var prefersStatusBarHidden: Bool {
|
|
statusBarController.isHidden
|
|
}
|
|
|
|
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
|
.fade
|
|
}
|
|
|
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
|
super.viewWillTransition(to: size, with: coordinator)
|
|
|
|
// Notify window manager that rotation is occurring
|
|
// This works for both system auto-rotate AND manual fullscreen toggle
|
|
onRotationTransition?()
|
|
|
|
// Update window frame to match new scene bounds after rotation or resize
|
|
// Use coordinateSpace.bounds for proper iPad Stage Manager / Split View support
|
|
coordinator.animate(alongsideTransition: { [weak self] _ in
|
|
guard let window = self?.view.window,
|
|
let scene = window.windowScene else { return }
|
|
window.frame = scene.coordinateSpace.bounds
|
|
})
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
|
|
// Ensure window size stays in sync with scene bounds during iPad resize
|
|
// This handles Stage Manager / Split View live resize where viewWillTransition may not be called
|
|
// Only update SIZE, not origin - origin is managed by drag-to-dismiss gesture
|
|
guard let window = view.window,
|
|
let scene = window.windowScene else { return }
|
|
let sceneSize = scene.coordinateSpace.bounds.size
|
|
if window.frame.size != sceneSize {
|
|
window.frame.size = sceneSize
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handles drag-to-dismiss by tracking scroll view overscroll
|
|
private final class DragToDismissGestureHandler: NSObject, UIGestureRecognizerDelegate {
|
|
weak var window: UIWindow?
|
|
var onDismiss: (() -> Void)?
|
|
var onDismissGestureStateChanged: ((Bool) -> Void)?
|
|
var onHapticFeedback: (() -> Void)?
|
|
|
|
// Panscan callbacks
|
|
var onPanscanChanged: ((Double) -> Void)?
|
|
var onPinchGestureStateChanged: ((Bool) -> Void)?
|
|
var getCurrentPanscan: (() -> Double)?
|
|
/// Returns true if panscan should snap to fit/fill when released
|
|
var shouldSnapPanscan: (() -> Bool)?
|
|
/// Returns true if panel is both pinned AND visible on screen
|
|
var isPanelPinnedAndVisible: (() -> Bool)?
|
|
/// Returns true if expanded comments view is showing (blocks sheet dismiss)
|
|
var isCommentsExpanded: (() -> Bool)?
|
|
/// Returns true if user is adjusting volume/brightness sliders (blocks sheet dismiss)
|
|
var isAdjustingPlayerSliders: (() -> Bool)?
|
|
/// Returns true if user is dragging the portrait panel (blocks sheet dismiss)
|
|
var isPanelDragging: (() -> Bool)?
|
|
/// Returns the portrait panel frame in screen coordinates (for gesture conflict resolution)
|
|
var getPortraitPanelFrame: (() -> CGRect)?
|
|
/// Returns whether the portrait panel is currently visible (not hidden off-screen)
|
|
var isPortraitPanelVisible: (() -> Bool)?
|
|
/// Returns the progress bar frame in screen coordinates (for gesture conflict resolution)
|
|
var getProgressBarFrame: (() -> CGRect)?
|
|
/// Returns true if a seek gesture is currently active (blocks pinch gesture)
|
|
var isSeekGestureActive: (() -> Bool)?
|
|
|
|
// 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
|
|
}
|
|
|
|
// Don't begin dismiss gesture when comments are expanded (they handle their own dismiss)
|
|
if isCommentsExpanded?() == true {
|
|
return false
|
|
}
|
|
|
|
// Don't begin dismiss gesture when adjusting volume/brightness sliders
|
|
if isAdjustingPlayerSliders?() == true {
|
|
return false
|
|
}
|
|
|
|
// Don't begin dismiss gesture when dragging portrait panel
|
|
if isPanelDragging?() == true {
|
|
return false
|
|
}
|
|
|
|
// When portrait panel is visible, only allow dismiss from player area (above panel)
|
|
// This prevents race conditions where isPanelDragging isn't set yet when gesture begins
|
|
let panelFrame = getPortraitPanelFrame?() ?? .zero
|
|
let panelVisible = isPortraitPanelVisible?() ?? true
|
|
if !panelFrame.isEmpty && panelVisible {
|
|
let touchLocation = panGesture.location(in: nil) // screen coordinates
|
|
// Block if touch is at or below the panel's top edge
|
|
// This ensures dismiss only works on the player area
|
|
if touchLocation.y >= panelFrame.minY {
|
|
return false
|
|
}
|
|
}
|
|
|
|
let velocity = panGesture.velocity(in: gestureRecognizer.view)
|
|
// Only for downward gestures
|
|
return velocity.y > 0 && velocity.y > abs(velocity.x)
|
|
}
|
|
|
|
// Allow simultaneous recognition with scroll views
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
// Our pinch gesture should recognize simultaneously with everything
|
|
// This allows SwiftUI's MagnificationGesture to also receive the touches
|
|
if gestureRecognizer is UIPinchGestureRecognizer {
|
|
return true
|
|
}
|
|
|
|
// Pan gesture should not recognize simultaneously with pinch gestures
|
|
if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPinchGestureRecognizer {
|
|
return false
|
|
}
|
|
|
|
// Track the scroll view if we find one
|
|
if let scrollView = otherGestureRecognizer.view as? UIScrollView, trackedScrollView == nil {
|
|
trackScrollView(scrollView)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Block pan gesture from receiving touches on slider and progress bar controls
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
// Only filter for pan gestures (our dismiss gesture)
|
|
guard gestureRecognizer is UIPanGestureRecognizer else { return true }
|
|
|
|
// Check if touch is within progress bar frame (coordinate-based detection)
|
|
// This is needed because SwiftUI overlays are transparent to UIKit hit-testing
|
|
if let progressBarFrame = getProgressBarFrame?(), progressBarFrame != .zero {
|
|
let locationInWindow = touch.location(in: window)
|
|
// Convert window location to screen coordinates
|
|
if let window = window {
|
|
let locationInScreen = window.convert(locationInWindow, to: nil)
|
|
if progressBarFrame.contains(locationInScreen) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if touch is on a slider (hit-test based detection)
|
|
let location = touch.location(in: window)
|
|
if let hitView = window?.hitTest(location, with: nil) {
|
|
// Walk up the view hierarchy to find if we're touching a slider
|
|
var view: UIView? = hitView
|
|
while let currentView = view {
|
|
// Check for UISlider (SwiftUI Slider wraps this on iOS)
|
|
if currentView is UISlider {
|
|
return false
|
|
}
|
|
// Check class name for SwiftUI slider hosting views
|
|
let className = String(describing: type(of: currentView))
|
|
if className.contains("Slider") {
|
|
return false
|
|
}
|
|
view = currentView.superview
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ExpandedPlayerWindowManager {
|
|
static let shared = ExpandedPlayerWindowManager()
|
|
|
|
private var expandedWindow: UIWindow?
|
|
private weak var appEnvironment: AppEnvironment?
|
|
private var dragHandler: DragToDismissGestureHandler?
|
|
|
|
// MARK: - Window Scaling Effect (Apple Music-style)
|
|
|
|
/// How much to scale down the main window (0.1 = 10% smaller)
|
|
private let maxScaleProgress: CGFloat = 0.08
|
|
/// Corner radius when fully scaled
|
|
private let scaledCornerRadius: CGFloat = 50
|
|
|
|
/// Cached reference to the view we're scaling (first subview of main window)
|
|
/// We cache this to ensure we always reset the same view we scaled
|
|
private weak var scaledPresentingView: UIView?
|
|
|
|
/// Tracks whether orientation changed during this player session
|
|
/// When rotation occurs, we skip animated scale reset to avoid frame glitches
|
|
private var didRotateDuringSession = false
|
|
|
|
/// Tracks if scale was skipped during show() because app was inactive
|
|
/// When true, we need to apply scale once app becomes active
|
|
private var pendingScaleApplication = false
|
|
|
|
/// Pending show request when scene wasn't ready (for retry)
|
|
private var pendingShowRequest: (appEnvironment: AppEnvironment, animated: Bool)?
|
|
|
|
/// Retry count for show() when scene isn't ready (max 3)
|
|
private var showRetryCount = 0
|
|
private let maxShowRetries = 3
|
|
|
|
private var backgroundObserver: (any NSObjectProtocol)?
|
|
private var foregroundObserver: (any NSObjectProtocol)?
|
|
|
|
private init() {
|
|
setupBackgroundObservers()
|
|
}
|
|
|
|
private func setupBackgroundObservers() {
|
|
// Reset main window transform when app goes to background
|
|
// This prevents visual glitches in the app switcher
|
|
backgroundObserver = NotificationCenter.default.addObserver(
|
|
forName: UIApplication.willResignActiveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
|
|
// If player is expanded when going inactive (e.g., Control Center),
|
|
// mark that we went through a transition. This ensures we use immediate
|
|
// reset on dismiss instead of animated, avoiding transform glitches.
|
|
if self.expandedWindow != nil {
|
|
self.didRotateDuringSession = true
|
|
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] willResignActive: player expanded, marking didRotateDuringSession=true")
|
|
}
|
|
|
|
self.resetMainWindowImmediate()
|
|
}
|
|
}
|
|
|
|
// Re-apply main window transform when app comes back to foreground
|
|
// (if the expanded player is still showing)
|
|
// Also processes any pending show requests that failed due to scene not being ready
|
|
foregroundObserver = NotificationCenter.default.addObserver(
|
|
forName: UIApplication.didBecomeActiveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
|
|
// Check if we have a pending show request from when scene wasn't ready
|
|
if let pending = self.pendingShowRequest {
|
|
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] didBecomeActive: processing pending show request")
|
|
self.pendingShowRequest = nil
|
|
// Small delay to ensure scene is fully active
|
|
try? await Task.sleep(for: .milliseconds(50))
|
|
self.show(with: pending.appEnvironment, animated: pending.animated)
|
|
} else if self.expandedWindow != nil {
|
|
// Check if we need to apply scale that was deferred during show()
|
|
if self.pendingScaleApplication {
|
|
self.pendingScaleApplication = false
|
|
// Animate the scale application since we deferred it
|
|
UIView.animate(withDuration: 0.25) {
|
|
self.scaleMainWindow(progress: 1.0)
|
|
}
|
|
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] didBecomeActive: applied deferred scale (animated)")
|
|
} else if !self.didRotateDuringSession {
|
|
// Re-apply scale if no transition occurred (Control Center, etc.)
|
|
// If didRotateDuringSession is true, we already reset in willResignActive
|
|
// and should NOT re-apply the scale to avoid frame corruption on dismiss
|
|
self.scaleMainWindow(progress: 1.0)
|
|
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] didBecomeActive: re-applied scale")
|
|
} else {
|
|
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] didBecomeActive: skipping scale (didRotateDuringSession=true)")
|
|
}
|
|
} else {
|
|
// Player was closed - ensure transform is reset
|
|
self.resetMainWindowImmediate()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Main Window Scaling
|
|
|
|
/// The main app window (the one behind the expanded player)
|
|
private var mainWindow: UIWindow? {
|
|
// Try to find window from any available scene (not just foregroundActive)
|
|
// This handles Control Center being open (foregroundInactive) and other transitions
|
|
let allScenes: [UIWindowScene] = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
let activeScene: UIWindowScene? = allScenes.first { $0.activationState == .foregroundActive }
|
|
let inactiveScene: UIWindowScene? = allScenes.first { $0.activationState == .foregroundInactive }
|
|
let windowScene: UIWindowScene? = activeScene ?? inactiveScene
|
|
|
|
guard let windowScene else {
|
|
LoggingService.shared.logPlayer("[WindowScale] mainWindow: no suitable window scene found")
|
|
return nil
|
|
}
|
|
|
|
// Find the main window (not our expanded player window)
|
|
let expandedWin = expandedWindow
|
|
let window = windowScene.windows.first { $0 !== expandedWin && $0.windowLevel == .normal }
|
|
|
|
if window == nil {
|
|
LoggingService.shared.logPlayer("[WindowScale] mainWindow: no main window found in scene (state: \(windowScene.activationState.rawValue))")
|
|
}
|
|
|
|
return window
|
|
}
|
|
|
|
/// The view to apply transforms to (first subview of main window)
|
|
/// Note: For actual transformations, use scaledPresentingView which caches the reference
|
|
private var presentingView: UIView? {
|
|
mainWindow?.subviews.first
|
|
}
|
|
|
|
/// Current window height for calculating transforms
|
|
private var windowHeight: CGFloat {
|
|
mainWindow?.bounds.height ?? UIScreen.main.bounds.height
|
|
}
|
|
|
|
/// Applies scale and corner radius to main window based on progress (0 = normal, 1 = fully scaled)
|
|
func scaleMainWindow(progress: CGFloat) {
|
|
// Capture the presenting view reference on first call
|
|
if scaledPresentingView == nil {
|
|
scaledPresentingView = mainWindow?.subviews.first
|
|
LoggingService.shared.logPlayer("[WindowScale] Captured presentingView: \(String(describing: scaledPresentingView)), bounds: \(scaledPresentingView?.bounds ?? .zero)")
|
|
}
|
|
|
|
guard let presentingView = scaledPresentingView else {
|
|
LoggingService.shared.logPlayer("[WindowScale] scaleMainWindow(\(progress)) - NO presentingView!")
|
|
return
|
|
}
|
|
|
|
// Clamp progress to 0-1 range
|
|
let clampedProgress = max(0, min(1, progress))
|
|
|
|
// Calculate actual scale progress (e.g., 0.08 means scale to 0.92)
|
|
let scaleAmount = clampedProgress * maxScaleProgress
|
|
let scale = 1 - scaleAmount
|
|
|
|
// Offset to keep the view centered vertically after scaling
|
|
let offsetY = (windowHeight * scaleAmount) / 2
|
|
|
|
LoggingService.shared.logPlayer("[WindowScale] scaleMainWindow(\(progress)) - scale: \(scale), offsetY: \(offsetY), windowHeight: \(windowHeight)")
|
|
|
|
// Apply corner radius proportional to progress
|
|
presentingView.layer.cornerRadius = clampedProgress * scaledCornerRadius
|
|
presentingView.layer.masksToBounds = true
|
|
|
|
// Apply scale and translation transform
|
|
presentingView.transform = CGAffineTransform.identity
|
|
.scaledBy(x: scale, y: scale)
|
|
.translatedBy(x: 0, y: offsetY)
|
|
}
|
|
|
|
/// Animates main window scale to target progress with spring animation
|
|
func scaleMainWindowAnimated(to progress: CGFloat, duration: TimeInterval = 0.3, damping: CGFloat = 0.7) {
|
|
guard let presentingView = scaledPresentingView ?? mainWindow?.subviews.first else { return }
|
|
|
|
let clampedProgress = max(0, min(1, progress))
|
|
let scaleAmount = clampedProgress * maxScaleProgress
|
|
let scale = 1 - scaleAmount
|
|
let offsetY = (windowHeight * scaleAmount) / 2
|
|
let cornerRadius = clampedProgress * scaledCornerRadius
|
|
|
|
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: []) {
|
|
presentingView.transform = CGAffineTransform.identity
|
|
.scaledBy(x: scale, y: scale)
|
|
.translatedBy(x: 0, y: offsetY)
|
|
presentingView.layer.cornerRadius = cornerRadius
|
|
}
|
|
}
|
|
|
|
/// Resets main window to normal state with animation
|
|
func resetMainWindowAnimated(duration: TimeInterval = 0.3) {
|
|
let view = scaledPresentingView ?? mainWindow?.subviews.first
|
|
UIView.animate(withDuration: duration) {
|
|
view?.layer.cornerRadius = 0
|
|
view?.transform = .identity
|
|
} completion: { [weak self] _ in
|
|
view?.layer.masksToBounds = false
|
|
self?.scaledPresentingView = nil
|
|
}
|
|
}
|
|
|
|
/// Resets main window to normal state immediately
|
|
func resetMainWindowImmediate() {
|
|
// Reset ALL subviews of the main window to handle rotation edge cases
|
|
// During rotation, UIKit may replace/rearrange transition views
|
|
guard let window = mainWindow else {
|
|
scaledPresentingView = nil
|
|
return
|
|
}
|
|
|
|
for subview in window.subviews {
|
|
// Remove any in-flight animations
|
|
subview.layer.removeAllAnimations()
|
|
subview.layer.cornerRadius = 0
|
|
subview.transform = .identity
|
|
|
|
// Reset frame to match window bounds (fix any rotation-induced frame issues)
|
|
subview.frame = window.bounds
|
|
|
|
// Force layout to apply changes immediately
|
|
subview.setNeedsLayout()
|
|
subview.layoutIfNeeded()
|
|
|
|
// Reset clipping
|
|
subview.clipsToBounds = false
|
|
subview.layer.masksToBounds = false
|
|
}
|
|
|
|
// Force the window itself to layout
|
|
window.setNeedsLayout()
|
|
window.layoutIfNeeded()
|
|
|
|
// Clear the cached reference
|
|
scaledPresentingView = nil
|
|
}
|
|
|
|
/// Called when rotation occurs during the player session (via viewWillTransition)
|
|
/// This works for both system auto-rotate AND manual fullscreen toggle
|
|
private func handleRotationDuringSession() {
|
|
guard expandedWindow != nil else { return }
|
|
|
|
// Mark that rotation is in progress - this temporarily disables scale effects
|
|
didRotateDuringSession = true
|
|
|
|
// Immediately reset main window transforms to prevent frame corruption
|
|
resetMainWindowImmediate()
|
|
|
|
LoggingService.shared.logPlayer("[WindowScale] handleRotationDuringSession: rotation detected, reset transforms")
|
|
|
|
// After rotation animation completes, re-apply scale effect so drag-to-dismiss works
|
|
// Use a delay to let the system rotation animation settle
|
|
Task { @MainActor [weak self] in
|
|
try? await Task.sleep(for: .milliseconds(400))
|
|
guard let self, self.expandedWindow != nil else { return }
|
|
|
|
// Re-apply scale effect with animation
|
|
self.didRotateDuringSession = false
|
|
UIView.animate(withDuration: 0.25) {
|
|
self.scaleMainWindow(progress: 1.0)
|
|
}
|
|
LoggingService.shared.logPlayer("[WindowScale] handleRotationDuringSession: rotation settled, re-applied scale")
|
|
}
|
|
}
|
|
|
|
var isPresented: Bool {
|
|
expandedWindow != nil
|
|
}
|
|
|
|
func show(with appEnvironment: AppEnvironment, animated: Bool = true) {
|
|
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show called, expandedWindow=\(expandedWindow != nil), animated=\(animated), retryCount=\(showRetryCount)")
|
|
guard expandedWindow == nil else { return }
|
|
|
|
self.appEnvironment = appEnvironment
|
|
|
|
// Get the active window scene - allow foregroundInactive for Control Center scenarios
|
|
let allScenes: [UIWindowScene] = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
|
let activeScene: UIWindowScene? = allScenes.first { $0.activationState == .foregroundActive }
|
|
let inactiveScene: UIWindowScene? = allScenes.first { $0.activationState == .foregroundInactive }
|
|
let windowScene: UIWindowScene? = activeScene ?? inactiveScene
|
|
|
|
guard let windowScene else {
|
|
// Scene not ready - retry if we haven't exceeded max retries
|
|
if showRetryCount < maxShowRetries {
|
|
showRetryCount += 1
|
|
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show: no suitable window scene, scheduling retry \(showRetryCount)/\(maxShowRetries)")
|
|
pendingShowRequest = (appEnvironment, animated)
|
|
Task { @MainActor [weak self] in
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
guard let self, let pending = self.pendingShowRequest else { return }
|
|
self.pendingShowRequest = nil
|
|
self.show(with: pending.appEnvironment, animated: pending.animated)
|
|
}
|
|
} else {
|
|
// Exceeded max retries - give up and reset state
|
|
LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show: FAILED after \(maxShowRetries) retries, resetting state")
|
|
showRetryCount = 0
|
|
pendingShowRequest = nil
|
|
appEnvironment.navigationCoordinator.isPlayerExpanded = false
|
|
}
|
|
return
|
|
}
|
|
|
|
// Success - clear retry state
|
|
showRetryCount = 0
|
|
pendingShowRequest = nil
|
|
|
|
// Create a new window at a level above normal content but below alerts
|
|
let window = UIWindow(windowScene: windowScene)
|
|
window.windowLevel = .normal + 1 // Above main content, below fullscreen player
|
|
window.backgroundColor = .clear // Clear for dismiss gesture; SwiftUI provides background
|
|
|
|
// Create status bar controller
|
|
let statusBarController = StatusBarVisibilityController()
|
|
|
|
// Create the expanded player view with status bar controller in environment
|
|
let expandedView = ExpandedPlayerSheet()
|
|
.appEnvironment(appEnvironment)
|
|
.environment(\.statusBarVisibilityController, statusBarController)
|
|
|
|
let hostingController = ExpandedPlayerHostingController(
|
|
rootView: expandedView,
|
|
statusBarController: statusBarController
|
|
)
|
|
// Add safe area insets for tab bar to help PiP avoid covering it
|
|
// Using 50pt as conservative value (inline/minimized tab bar height)
|
|
hostingController.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: 46, right: 0)
|
|
hostingController.view.backgroundColor = .clear
|
|
// Ensure the hosting controller's view resizes with the window during rotation
|
|
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
|
|
// Handle rotation transitions - reset main window transforms immediately
|
|
// This works for both system auto-rotate AND manual fullscreen toggle
|
|
hostingController.onRotationTransition = { [weak self] in
|
|
self?.handleRotationDuringSession()
|
|
}
|
|
|
|
// Set viewController reference so controller can trigger updates
|
|
statusBarController.viewController = hostingController
|
|
|
|
window.rootViewController = hostingController
|
|
|
|
// Add drag-to-dismiss gesture to the window
|
|
let handler = DragToDismissGestureHandler()
|
|
handler.window = window
|
|
handler.onHapticFeedback = { [weak self] in
|
|
self?.appEnvironment?.settingsManager.triggerHapticFeedback(for: .playerDismiss)
|
|
}
|
|
handler.onDismiss = { [weak self] in
|
|
// Set collapsing state BEFORE isPlayerExpanded=false to ensure mini player
|
|
// shows video immediately when it appears (it checks isPlayerCollapsing)
|
|
self?.appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
|
|
self?.appEnvironment?.navigationCoordinator.isPlayerExpanded = false
|
|
}
|
|
handler.onDismissGestureStateChanged = { [weak self] isActive in
|
|
self?.appEnvironment?.navigationCoordinator.isPlayerDismissGestureActive = isActive
|
|
}
|
|
// Panscan callbacks - connect UIKit pinch gesture to NavigationCoordinator
|
|
handler.onPanscanChanged = { [weak self] panscan in
|
|
self?.appEnvironment?.navigationCoordinator.pinchPanscan = panscan
|
|
}
|
|
handler.onPinchGestureStateChanged = { [weak self] isActive in
|
|
self?.appEnvironment?.navigationCoordinator.isPinchGestureActive = isActive
|
|
}
|
|
handler.getCurrentPanscan = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.pinchPanscan ?? 0.0
|
|
}
|
|
handler.shouldSnapPanscan = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.shouldSnapPanscan ?? true
|
|
}
|
|
handler.isPanelPinnedAndVisible = { [weak self] in
|
|
guard let settings = self?.appEnvironment?.settingsManager else { return false }
|
|
return settings.landscapeDetailsPanelPinned && settings.landscapeDetailsPanelVisible
|
|
}
|
|
handler.isCommentsExpanded = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.isCommentsExpanded ?? false
|
|
}
|
|
handler.isAdjustingPlayerSliders = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders ?? false
|
|
}
|
|
handler.isPanelDragging = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.isPanelDragging ?? false
|
|
}
|
|
handler.getPortraitPanelFrame = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.portraitPanelFrame ?? .zero
|
|
}
|
|
handler.isPortraitPanelVisible = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.isPortraitPanelVisible ?? true
|
|
}
|
|
handler.getProgressBarFrame = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.progressBarFrame ?? .zero
|
|
}
|
|
handler.isSeekGestureActive = { [weak self] in
|
|
self?.appEnvironment?.navigationCoordinator.isSeekGestureActive ?? false
|
|
}
|
|
// 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
|