mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
404 lines
15 KiB
Swift
404 lines
15 KiB
Swift
//
|
|
// ExpandedPlayerWindowManager.swift
|
|
// Yattee
|
|
//
|
|
// Manages expanded player window on macOS.
|
|
// Uses a separate NSWindow for better control over presentation and floating behavior.
|
|
//
|
|
|
|
#if os(macOS)
|
|
import AppKit
|
|
import SwiftUI
|
|
|
|
/// Manages the expanded player window on macOS.
|
|
/// Supports both normal window and floating (always-on-top) modes.
|
|
@MainActor
|
|
final class ExpandedPlayerWindowManager: NSObject {
|
|
static let shared = ExpandedPlayerWindowManager()
|
|
|
|
private var playerWindow: NSWindow?
|
|
private weak var appEnvironment: AppEnvironment?
|
|
|
|
// Configuration
|
|
private let minWidth: CGFloat = 640
|
|
private let minHeight: CGFloat = 480
|
|
private let maxScreenRatio: CGFloat = 0.7
|
|
private let targetVideoHeight: CGFloat = 720
|
|
|
|
var isPresented: Bool {
|
|
playerWindow != nil
|
|
}
|
|
|
|
private override init() {
|
|
super.init()
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Shows the expanded player in a separate window.
|
|
/// - Parameters:
|
|
/// - appEnvironment: The app environment for state and services
|
|
/// - animated: Whether to animate the window appearance
|
|
func show(with appEnvironment: AppEnvironment, animated: Bool = true) {
|
|
// If window already exists (hidden for PiP), restore it instead of creating new one
|
|
if let existingWindow = playerWindow {
|
|
LoggingService.shared.debug("ExpandedPlayerWindowManager: show() - restoring existing window (was hidden for PiP)", category: .player)
|
|
if animated {
|
|
existingWindow.alphaValue = 0
|
|
existingWindow.makeKeyAndOrderFront(nil)
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = 0.25
|
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
existingWindow.animator().alphaValue = 1
|
|
}
|
|
} else {
|
|
existingWindow.alphaValue = 1
|
|
existingWindow.makeKeyAndOrderFront(nil)
|
|
}
|
|
return
|
|
}
|
|
|
|
self.appEnvironment = appEnvironment
|
|
|
|
// Mark expanding state for mini player coordination
|
|
appEnvironment.navigationCoordinator.isPlayerExpanding = true
|
|
|
|
// Get the current player mode for window configuration
|
|
let mode = appEnvironment.settingsManager.macPlayerMode
|
|
|
|
// Create the player view
|
|
let playerView = ExpandedPlayerSheet()
|
|
.appEnvironment(appEnvironment)
|
|
|
|
// Create hosting controller
|
|
let hostingController = NSHostingController(rootView: playerView)
|
|
|
|
// Calculate initial window size
|
|
let initialSize = calculateInitialWindowSize()
|
|
|
|
// Create window with appropriate style
|
|
let window = NSWindow(
|
|
contentRect: NSRect(origin: .zero, size: initialSize),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
// Configure window appearance
|
|
window.titlebarAppearsTransparent = true
|
|
window.titleVisibility = .hidden
|
|
window.isMovableByWindowBackground = true
|
|
window.backgroundColor = NSColor.windowBackgroundColor
|
|
window.minSize = NSSize(width: minWidth, height: minHeight)
|
|
window.contentViewController = hostingController
|
|
|
|
// Set up window delegate for close handling
|
|
// Make ExpandedPlayerWindowManager itself the delegate to avoid lifecycle issues
|
|
window.delegate = self
|
|
|
|
// Configure window level based on mode
|
|
configureWindowLevel(window, floating: mode.isFloating)
|
|
|
|
// Center window on screen
|
|
window.center()
|
|
|
|
// Store reference
|
|
self.playerWindow = window
|
|
|
|
// Show window
|
|
if animated {
|
|
window.alphaValue = 0
|
|
window.makeKeyAndOrderFront(nil)
|
|
NSAnimationContext.runAnimationGroup({ context in
|
|
context.duration = 0.25
|
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
window.animator().alphaValue = 1
|
|
}, completionHandler: {
|
|
Task { @MainActor in
|
|
appEnvironment.navigationCoordinator.isPlayerExpanding = false
|
|
}
|
|
})
|
|
} else {
|
|
window.makeKeyAndOrderFront(nil)
|
|
appEnvironment.navigationCoordinator.isPlayerExpanding = false
|
|
}
|
|
}
|
|
|
|
/// Hides and cleans up the player window.
|
|
/// - Parameters:
|
|
/// - animated: Whether to animate the dismissal
|
|
/// - completion: Called after the window is hidden
|
|
func hide(animated: Bool = true, completion: (() -> Void)? = nil) {
|
|
guard let window = playerWindow else {
|
|
completion?()
|
|
return
|
|
}
|
|
|
|
// Mark collapsing state for mini player coordination
|
|
appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
|
|
|
|
// Check if PiP is active - if so, just hide the window without destroying content
|
|
// The AVSampleBufferDisplayLayer needs to stay alive while PiP is active
|
|
let isPiPActive = (appEnvironment?.playerService.currentBackend as? MPVBackend)?.isPiPActive ?? false
|
|
|
|
LoggingService.shared.debug("ExpandedPlayerWindowManager: hide() called, isPiPActive=\(isPiPActive)", category: .player)
|
|
|
|
// Capture navigationCoordinator before closures to avoid Swift 6 concurrency warnings
|
|
let navigationCoordinator = appEnvironment?.navigationCoordinator
|
|
|
|
if isPiPActive {
|
|
// PiP is active - just hide the window, keep content alive
|
|
// Don't clear playerWindow reference so we can restore it later
|
|
let hideWindow: @Sendable () -> Void = {
|
|
Task { @MainActor in
|
|
navigationCoordinator?.isPlayerCollapsing = false
|
|
window.orderOut(nil)
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
if animated {
|
|
NSAnimationContext.runAnimationGroup({ context in
|
|
context.duration = 0.2
|
|
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
|
window.animator().alphaValue = 0
|
|
}, completionHandler: hideWindow)
|
|
} else {
|
|
hideWindow()
|
|
}
|
|
} else {
|
|
// PiP is not active - fully clean up the window
|
|
// Clear reference immediately to prevent re-entry
|
|
playerWindow = nil
|
|
|
|
let cleanup: @Sendable () -> Void = {
|
|
Task { @MainActor in
|
|
navigationCoordinator?.isPlayerCollapsing = false
|
|
window.delegate = nil
|
|
// Don't set contentViewController to nil or call close() - just order out
|
|
// This lets SwiftUI views deallocate naturally rather than being forcibly torn down
|
|
window.orderOut(nil)
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
if animated {
|
|
NSAnimationContext.runAnimationGroup({ context in
|
|
context.duration = 0.2
|
|
context.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
|
window.animator().alphaValue = 0
|
|
}, completionHandler: cleanup)
|
|
} else {
|
|
cleanup()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Updates the window level based on floating preference.
|
|
/// Call this when the user changes the player mode setting.
|
|
func updateWindowLevel(floating: Bool) {
|
|
guard let window = playerWindow else { return }
|
|
configureWindowLevel(window, floating: floating)
|
|
}
|
|
|
|
/// Restores a window that was hidden for PiP mode.
|
|
/// Call this when returning from PiP to show the player window again.
|
|
func restoreFromPiP(animated: Bool = true) {
|
|
guard let window = playerWindow else {
|
|
LoggingService.shared.debug("ExpandedPlayerWindowManager: restoreFromPiP - no window to restore", category: .player)
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.debug("ExpandedPlayerWindowManager: restoreFromPiP called", category: .player)
|
|
|
|
if animated {
|
|
window.alphaValue = 0
|
|
window.makeKeyAndOrderFront(nil)
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = 0.25
|
|
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
window.animator().alphaValue = 1
|
|
}
|
|
} else {
|
|
window.alphaValue = 1
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
}
|
|
|
|
/// Cleans up a window that was hidden for PiP when PiP ends without restoring.
|
|
/// Call this when PiP is closed via the X button (not restore).
|
|
func cleanupAfterPiP() {
|
|
guard let window = playerWindow else { return }
|
|
|
|
LoggingService.shared.debug("ExpandedPlayerWindowManager: cleanupAfterPiP called", category: .player)
|
|
|
|
// Clear our reference immediately to prevent further use
|
|
playerWindow = nil
|
|
window.delegate = nil
|
|
|
|
// Don't forcefully destroy the contentViewController or close the window immediately.
|
|
// This causes crashes because AVKit's PiP implementation adds internal views to the
|
|
// window hierarchy (via NSHostingController), and forcefully tearing down the view
|
|
// hierarchy while AVKit still has references causes use-after-free crashes.
|
|
//
|
|
// Instead, just order the window out and let it deallocate naturally when all
|
|
// references are released.
|
|
window.orderOut(nil)
|
|
|
|
LoggingService.shared.debug("ExpandedPlayerWindowManager: window ordered out", category: .player)
|
|
}
|
|
|
|
/// Resizes the player window to fit the given video aspect ratio.
|
|
/// - Parameters:
|
|
/// - aspectRatio: Video width / height ratio
|
|
/// - animated: Whether to animate the resize
|
|
func resizeToFitAspectRatio(_ aspectRatio: Double, animated: Bool = true) {
|
|
guard let window = playerWindow else { return }
|
|
guard aspectRatio > 0 else { return }
|
|
|
|
// Get screen bounds
|
|
guard let screen = window.screen ?? NSScreen.main else { return }
|
|
let screenFrame = screen.visibleFrame
|
|
|
|
// Calculate target size
|
|
let targetSize = calculateWindowSize(for: aspectRatio, screenFrame: screenFrame)
|
|
|
|
// Calculate new frame centered on current position
|
|
let currentFrame = window.frame
|
|
let newOrigin = NSPoint(
|
|
x: currentFrame.midX - targetSize.width / 2,
|
|
y: currentFrame.midY - targetSize.height / 2
|
|
)
|
|
let newFrame = NSRect(origin: newOrigin, size: targetSize)
|
|
|
|
// Ensure frame stays on screen
|
|
let adjustedFrame = constrainToScreen(newFrame, screen: screen)
|
|
|
|
// Apply the new frame
|
|
window.setFrame(adjustedFrame, display: true, animate: animated)
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
private func configureWindowLevel(_ window: NSWindow, floating: Bool) {
|
|
if floating {
|
|
window.level = .floating
|
|
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
} else {
|
|
window.level = .normal
|
|
window.collectionBehavior = [.managed, .fullScreenPrimary]
|
|
}
|
|
}
|
|
|
|
private func calculateInitialWindowSize() -> NSSize {
|
|
guard let screen = NSScreen.main else {
|
|
return NSSize(width: 1280, height: 720)
|
|
}
|
|
|
|
let screenFrame = screen.visibleFrame
|
|
let maxWidth = screenFrame.width * maxScreenRatio
|
|
let maxHeight = screenFrame.height * maxScreenRatio
|
|
|
|
// Start with 16:9 aspect ratio at target height
|
|
var width: CGFloat = targetVideoHeight * 16 / 9
|
|
var height: CGFloat = targetVideoHeight
|
|
|
|
// Scale down if needed
|
|
if width > maxWidth {
|
|
width = maxWidth
|
|
height = width * 9 / 16
|
|
}
|
|
if height > maxHeight {
|
|
height = maxHeight
|
|
width = height * 16 / 9
|
|
}
|
|
|
|
return NSSize(width: max(width, minWidth), height: max(height, minHeight))
|
|
}
|
|
|
|
private func calculateWindowSize(for aspectRatio: Double, screenFrame: NSRect) -> NSSize {
|
|
let maxWidth = screenFrame.width * maxScreenRatio
|
|
let maxHeight = screenFrame.height * maxScreenRatio
|
|
|
|
var width: CGFloat
|
|
var height: CGFloat
|
|
|
|
// Start with target video height and calculate width from aspect ratio
|
|
height = targetVideoHeight
|
|
width = height * aspectRatio
|
|
|
|
// Scale down if too wide for screen
|
|
if width > maxWidth {
|
|
width = maxWidth
|
|
height = width / aspectRatio
|
|
}
|
|
|
|
// Scale down if too tall for screen
|
|
if height > maxHeight {
|
|
height = maxHeight
|
|
width = height * aspectRatio
|
|
}
|
|
|
|
// Apply minimum constraints
|
|
width = max(width, minWidth)
|
|
height = max(height, minHeight)
|
|
|
|
return NSSize(width: width, height: height)
|
|
}
|
|
|
|
private func constrainToScreen(_ frame: NSRect, screen: NSScreen) -> NSRect {
|
|
let screenFrame = screen.visibleFrame
|
|
var adjustedFrame = frame
|
|
|
|
// Ensure width/height don't exceed screen
|
|
adjustedFrame.size.width = min(adjustedFrame.size.width, screenFrame.width)
|
|
adjustedFrame.size.height = min(adjustedFrame.size.height, screenFrame.height)
|
|
|
|
// Adjust origin to keep on screen
|
|
if adjustedFrame.minX < screenFrame.minX {
|
|
adjustedFrame.origin.x = screenFrame.minX
|
|
}
|
|
if adjustedFrame.maxX > screenFrame.maxX {
|
|
adjustedFrame.origin.x = screenFrame.maxX - adjustedFrame.width
|
|
}
|
|
if adjustedFrame.minY < screenFrame.minY {
|
|
adjustedFrame.origin.y = screenFrame.minY
|
|
}
|
|
if adjustedFrame.maxY > screenFrame.maxY {
|
|
adjustedFrame.origin.y = screenFrame.maxY - adjustedFrame.height
|
|
}
|
|
|
|
return adjustedFrame
|
|
}
|
|
}
|
|
|
|
// MARK: - NSWindowDelegate
|
|
|
|
extension ExpandedPlayerWindowManager: NSWindowDelegate {
|
|
nonisolated func windowShouldClose(_ sender: NSWindow) -> Bool {
|
|
// Handle close ourselves to avoid deallocation race conditions
|
|
MainActor.assumeIsolated {
|
|
// Clear reference first so hide() becomes a no-op
|
|
playerWindow = nil
|
|
|
|
// Stop player BEFORE cleaning up window to avoid crash
|
|
// The player must be stopped while views still exist to ensure
|
|
// proper cleanup of render resources
|
|
appEnvironment?.playerService.stop()
|
|
|
|
// Clean up window
|
|
sender.delegate = nil
|
|
sender.contentViewController = nil
|
|
sender.orderOut(nil)
|
|
|
|
// Update navigation state
|
|
// Set collapsing first so mini player shows video immediately
|
|
appEnvironment?.navigationCoordinator.isPlayerCollapsing = true
|
|
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
|
|
}
|
|
// Return false - we've already hidden the window with orderOut
|
|
return false
|
|
}
|
|
}
|
|
#endif
|