615 lines
27 KiB
Swift
615 lines
27 KiB
Swift
import AppKit
|
|
import QuartzCore
|
|
|
|
@MainActor
|
|
final class CompactWindowController: NSObject {
|
|
var onChange: (() -> Void)?
|
|
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
|
|
private(set) var isCompact = false
|
|
private(set) var isTransitioning = false
|
|
var presentationWindow: NSWindow? { compactPanel ?? window }
|
|
private(set) var queueHeight: CGFloat = 0
|
|
static let transitionDuration: TimeInterval = 0.25
|
|
|
|
var snappingEnabled: Bool {
|
|
didSet {
|
|
guard snappingEnabled != oldValue else { return }
|
|
onChange?()
|
|
}
|
|
}
|
|
|
|
private struct Snapshot {
|
|
let frame: NSRect
|
|
let minSize: NSSize
|
|
let contentMinSize: NSSize
|
|
let aspectRatio: NSSize
|
|
let resizeIncrements: NSSize
|
|
let styleMask: NSWindow.StyleMask
|
|
let titleVisibility: NSWindow.TitleVisibility
|
|
let titlebarAppearsTransparent: Bool
|
|
let collectionBehavior: NSWindow.CollectionBehavior
|
|
let level: NSWindow.Level
|
|
let hidesOnDeactivate: Bool
|
|
let isMovableByWindowBackground: Bool
|
|
let isVisible: Bool
|
|
let firstResponder: NSResponder?
|
|
let contentSuperview: NSView?
|
|
let contentFrame: NSRect
|
|
|
|
@MainActor
|
|
init(_ window: NSWindow, contentView: NSView) {
|
|
frame = window.frame
|
|
minSize = window.minSize
|
|
contentMinSize = window.contentMinSize
|
|
aspectRatio = window.aspectRatio
|
|
resizeIncrements = window.resizeIncrements
|
|
styleMask = window.styleMask
|
|
titleVisibility = window.titleVisibility
|
|
titlebarAppearsTransparent = window.titlebarAppearsTransparent
|
|
collectionBehavior = window.collectionBehavior
|
|
level = window.level
|
|
hidesOnDeactivate = window.hidesOnDeactivate
|
|
isMovableByWindowBackground = window.isMovableByWindowBackground
|
|
isVisible = window.isVisible
|
|
firstResponder = window.firstResponder
|
|
contentSuperview = contentView === window.contentView ? nil : contentView.superview
|
|
contentFrame = contentView.frame
|
|
}
|
|
}
|
|
|
|
private enum FullScreenTransition { case entering, exiting }
|
|
|
|
private weak var window: NSWindow?
|
|
private weak var contentView: NSView?
|
|
private var compactPanel: NSPanel?
|
|
private var panelDelegate: FullScreenDelegate?
|
|
private var snapshot: Snapshot?
|
|
private var fullScreenTransition: FullScreenTransition?
|
|
private var delegateProxy: FullScreenDelegate?
|
|
private var sessionActive = false
|
|
private var wantsCompact = false
|
|
private var wantsFullScreen = false
|
|
private var originatedFullScreen = false
|
|
private var videoAspect = 16.0 / 9.0
|
|
private var fittedAspect: Double?
|
|
private var fittedVideoAspect = 16.0 / 9.0
|
|
private let reduceMotion: () -> Bool
|
|
private let defaults: UserDefaults
|
|
private static let widthKey = "compactWindowWidth"
|
|
private var frameAnimating = false
|
|
private var frameAnimationGeneration: UInt64 = 0
|
|
|
|
init(snappingEnabled: Bool = true, defaults: UserDefaults = .standard,
|
|
reduceMotion: @escaping () -> Bool = { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }) {
|
|
self.snappingEnabled = snappingEnabled
|
|
self.reduceMotion = reduceMotion
|
|
self.defaults = defaults
|
|
super.init()
|
|
}
|
|
|
|
deinit { NotificationCenter.default.removeObserver(self) }
|
|
|
|
func attach(_ window: NSWindow, contentView: NSView? = nil) {
|
|
guard window !== self.window else { return }
|
|
stopFrameAnimation()
|
|
if let oldWindow = self.window, fullScreenTransition == nil,
|
|
!oldWindow.styleMask.contains(.fullScreen) {
|
|
restoreSnapshot(to: oldWindow)
|
|
}
|
|
restoreDelegate()
|
|
NotificationCenter.default.removeObserver(self)
|
|
self.window = window
|
|
self.contentView = contentView ?? window.contentView
|
|
snapshot = nil
|
|
fullScreenTransition = nil
|
|
sessionActive = false
|
|
wantsCompact = false
|
|
wantsFullScreen = false
|
|
originatedFullScreen = false
|
|
setState(compact: false, transitioning: false)
|
|
let proxy = FullScreenDelegate(owner: self, original: window.delegate)
|
|
delegateProxy = proxy
|
|
window.delegate = proxy
|
|
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.didEnterFullScreenNotification,
|
|
NSWindow.willExitFullScreenNotification, NSWindow.didExitFullScreenNotification,
|
|
NSWindow.willCloseNotification, NSWindow.didEndLiveResizeNotification] {
|
|
NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)),
|
|
name: name, object: window)
|
|
}
|
|
}
|
|
|
|
func toggle(aspect: Double) {
|
|
if sessionActive && wantsCompact {
|
|
restore()
|
|
return
|
|
}
|
|
guard let window else { return }
|
|
if !sessionActive {
|
|
sessionActive = true
|
|
originatedFullScreen = window.styleMask.contains(.fullScreen) || fullScreenTransition != nil
|
|
}
|
|
// Keep the last usable ratio until loading supplies the video's metadata.
|
|
if aspect.isFinite && aspect > 0 { videoAspect = aspect }
|
|
wantsCompact = true
|
|
wantsFullScreen = false
|
|
reconcile()
|
|
}
|
|
|
|
func restore(resumeFullScreen: Bool = true, animated: Bool = true) {
|
|
guard sessionActive else { return }
|
|
wantsCompact = false
|
|
wantsFullScreen = resumeFullScreen && originatedFullScreen
|
|
if !animated { stopFrameAnimation() }
|
|
reconcile(animated: animated)
|
|
}
|
|
|
|
func updateAspect(_ aspect: Double) {
|
|
guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return }
|
|
videoAspect = aspect
|
|
guard isCompact, !isTransitioning, let window = presentationWindow else { return }
|
|
fitCompactWindow(window, initial: false)
|
|
}
|
|
|
|
// The reveal and native frame share the model's animation clock.
|
|
func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? {
|
|
guard isCompact, !isTransitioning, let window = presentationWindow else { return nil }
|
|
let usable = Self.usableFrame(visibleFrame(for: window))
|
|
let height = visible ? min(240, max(0, usable.height - 90)) : 0
|
|
let video = Self.compactSize(aspect: videoAspect, width: window.frame.width,
|
|
available: NSSize(width: usable.width, height: usable.height - height))
|
|
let size = NSSize(width: video.width, height: video.height + height)
|
|
let frame = NSRect(x: min(max(window.frame.minX, usable.minX), usable.maxX - size.width),
|
|
y: max(usable.minY, min(window.frame.maxY, usable.maxY) - size.height),
|
|
width: size.width, height: size.height)
|
|
return (height, frame)
|
|
}
|
|
|
|
func updateQueue(height: CGFloat, frame: NSRect) {
|
|
guard isCompact, let window = presentationWindow else { return }
|
|
window.resizeIncrements = NSSize(width: 1, height: 1)
|
|
queueHeight = height
|
|
fittedVideoAspect = frame.width / (frame.height - height)
|
|
window.minSize = NSSize(width: 160, height: 90 + height)
|
|
window.contentMinSize = window.minSize
|
|
window.setFrame(frame, display: true)
|
|
if height == 0, Self.canFitAspect(videoAspect, available: Self.usableFrame(visibleFrame(for: window)).size) {
|
|
window.aspectRatio = NSSize(width: videoAspect, height: 1)
|
|
}
|
|
onChange?()
|
|
}
|
|
|
|
func snapAfterDrag() {
|
|
guard isCompact, !isTransitioning, snappingEnabled, let window = presentationWindow else { return }
|
|
let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window))
|
|
if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) }
|
|
}
|
|
|
|
@objc private func windowNotification(_ notification: Notification) {
|
|
switch notification.name {
|
|
case NSWindow.didEndLiveResizeNotification:
|
|
guard isCompact, !isTransitioning, let window = presentationWindow,
|
|
window.frame.width.isFinite, window.frame.width > 0 else { return }
|
|
defaults.set(Double(window.frame.width), forKey: Self.widthKey)
|
|
case NSWindow.willEnterFullScreenNotification:
|
|
fullScreenTransition = .entering
|
|
setState(compact: isCompact, transitioning: true)
|
|
case NSWindow.willExitFullScreenNotification:
|
|
fullScreenTransition = .exiting
|
|
setState(compact: isCompact, transitioning: true)
|
|
case NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification:
|
|
fullScreenTransition = nil
|
|
reconcile()
|
|
case NSWindow.willCloseNotification:
|
|
if notification.object as? NSWindow === compactPanel {
|
|
window?.close()
|
|
return
|
|
}
|
|
stopFrameAnimation()
|
|
if let window { removeCompactPanel(returningContentTo: window) }
|
|
restoreDelegate()
|
|
NotificationCenter.default.removeObserver(self)
|
|
window = nil
|
|
snapshot = nil
|
|
fullScreenTransition = nil
|
|
sessionActive = false
|
|
wantsCompact = false
|
|
wantsFullScreen = false
|
|
originatedFullScreen = false
|
|
setState(compact: false, transitioning: false)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func restoreDelegate() {
|
|
if let window, let delegateProxy, window.delegate === delegateProxy {
|
|
window.delegate = delegateProxy.original
|
|
}
|
|
delegateProxy = nil
|
|
}
|
|
|
|
private func fullScreenFailed() {
|
|
fullScreenTransition = nil
|
|
wantsFullScreen = false
|
|
if isCompact {
|
|
wantsCompact = true
|
|
setState(compact: true, transitioning: false)
|
|
} else {
|
|
if let window, !window.styleMask.contains(.fullScreen) { restoreSnapshot(to: window) }
|
|
finishSession()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FullScreenDelegate: NSObject, NSWindowDelegate {
|
|
// NSObject forwarding returns untyped Any; accesses remain on AppKit's main thread.
|
|
nonisolated(unsafe) weak var original: (any NSWindowDelegate)?
|
|
private weak var owner: CompactWindowController?
|
|
private var resizeStartSize: NSSize?
|
|
private var resizeWidthDriven: Bool?
|
|
|
|
init(owner: CompactWindowController, original: (any NSWindowDelegate)?) {
|
|
self.owner = owner
|
|
self.original = original
|
|
}
|
|
|
|
nonisolated override func responds(to selector: Selector!) -> Bool {
|
|
if super.responds(to: selector) { return true }
|
|
return MainActor.assumeIsolated { original?.responds(to: selector) ?? false }
|
|
}
|
|
|
|
nonisolated override func forwardingTarget(for selector: Selector!) -> Any? {
|
|
let responds = MainActor.assumeIsolated { original?.responds(to: selector) ?? false }
|
|
return responds ? original : nil
|
|
}
|
|
|
|
func windowWillStartLiveResize(_ notification: Notification) {
|
|
resizeStartSize = (notification.object as? NSWindow)?.frame.size
|
|
resizeWidthDriven = nil
|
|
original?.windowWillStartLiveResize?(notification)
|
|
}
|
|
|
|
func windowDidEndLiveResize(_ notification: Notification) {
|
|
resizeStartSize = nil
|
|
resizeWidthDriven = nil
|
|
original?.windowDidEndLiveResize?(notification)
|
|
}
|
|
|
|
func windowWillResize(_ sender: NSWindow, to proposedSize: NSSize) -> NSSize {
|
|
let proposed = original?.windowWillResize?(sender, to: proposedSize) ?? proposedSize
|
|
guard let owner, !owner.isTransitioning,
|
|
!sender.styleMask.contains(.fullScreen) else { return proposed }
|
|
let layout: (aspect: Double, sidebarWidth: CGFloat)
|
|
let extraHeight: CGFloat
|
|
if owner.isCompact {
|
|
guard owner.queueHeight > 0 else { return proposed }
|
|
layout = (owner.fittedVideoAspect, 0)
|
|
extraHeight = owner.queueHeight
|
|
} else {
|
|
guard let normal = owner.normalVideoLayout?(),
|
|
normal.aspect.isFinite, normal.aspect > 0 else { return proposed }
|
|
layout = normal
|
|
extraHeight = 0
|
|
}
|
|
// The sidebar stays fixed-width; only the video area is aspect-locked.
|
|
let chrome = owner.isCompact ? extraHeight
|
|
: sender.frame.height - sender.contentRect(forFrameRect: sender.frame).height
|
|
let start = resizeStartSize ?? sender.frame.size
|
|
let widthChange = abs(proposed.width - start.width)
|
|
let heightChange = abs(proposed.height - start.height) * layout.aspect
|
|
// Latch the user's axis once. Comparing against each corrected frame
|
|
// makes our own aspect adjustment look like a new drag on the other axis.
|
|
if resizeStartSize != nil, resizeWidthDriven == nil,
|
|
widthChange > 0 || heightChange > 0 {
|
|
resizeWidthDriven = widthChange >= heightChange
|
|
}
|
|
let widthDriven = resizeWidthDriven ?? (widthChange >= heightChange)
|
|
let minimumHeight = max(sender.minSize.height - chrome,
|
|
(sender.minSize.width - layout.sidebarWidth) / layout.aspect)
|
|
let height = max(minimumHeight, widthDriven
|
|
? (proposed.width - layout.sidebarWidth) / layout.aspect
|
|
: proposed.height - chrome)
|
|
return NSSize(width: height * layout.aspect + layout.sidebarWidth, height: height + chrome)
|
|
}
|
|
|
|
func windowDidFailToEnterFullScreen(_ window: NSWindow) {
|
|
owner?.fullScreenFailed()
|
|
original?.windowDidFailToEnterFullScreen?(window)
|
|
}
|
|
|
|
func windowDidFailToExitFullScreen(_ window: NSWindow) {
|
|
owner?.fullScreenFailed()
|
|
original?.windowDidFailToExitFullScreen?(window)
|
|
}
|
|
}
|
|
|
|
// A toggle changes the destination, never reverses an in-flight native animation.
|
|
private func reconcile(animated: Bool = true) {
|
|
guard let window, fullScreenTransition == nil, !frameAnimating else { return }
|
|
guard sessionActive else {
|
|
setState(compact: isCompact, transitioning: false)
|
|
return
|
|
}
|
|
if wantsCompact {
|
|
if window.styleMask.contains(.fullScreen) {
|
|
toggleNativeFullScreen(window, transition: .exiting)
|
|
} else if !isCompact {
|
|
enterCompact(window, animated: animated)
|
|
} else {
|
|
if fittedAspect != videoAspect, let panel = compactPanel { fitCompactWindow(panel, initial: false) }
|
|
setState(compact: true, transitioning: false)
|
|
}
|
|
return
|
|
}
|
|
if window.styleMask.contains(.fullScreen) {
|
|
if wantsFullScreen && snapshot == nil {
|
|
finishSession()
|
|
} else {
|
|
toggleNativeFullScreen(window, transition: .exiting)
|
|
}
|
|
return
|
|
}
|
|
if let snapshot {
|
|
queueHeight = 0
|
|
setState(compact: false, transitioning: true)
|
|
let restoringWindow = presentationWindow ?? window
|
|
restoringWindow.aspectRatio = .zero
|
|
restoringWindow.minSize = .zero
|
|
restoringWindow.contentMinSize = .zero
|
|
animateFrame(snapshot.frame, of: restoringWindow, animated: animated) { [weak self, weak window] in
|
|
guard let self, let window else { return }
|
|
self.restoreSnapshot(to: window)
|
|
self.reconcile()
|
|
}
|
|
return
|
|
}
|
|
if wantsFullScreen {
|
|
toggleNativeFullScreen(window, transition: .entering)
|
|
} else {
|
|
finishSession()
|
|
}
|
|
}
|
|
|
|
private func toggleNativeFullScreen(_ window: NSWindow, transition: FullScreenTransition) {
|
|
fullScreenTransition = transition
|
|
setState(compact: isCompact, transitioning: true)
|
|
window.toggleFullScreen(nil)
|
|
}
|
|
|
|
private func enterCompact(_ normalWindow: NSWindow, animated: Bool) {
|
|
// A regular NSWindow cannot join another app's native fullscreen Space.
|
|
// Keep the scene and renderer alive, but present their content in a nonactivating panel.
|
|
guard let content = contentView else {
|
|
finishSession()
|
|
return
|
|
}
|
|
let snapshot = Snapshot(normalWindow, contentView: content)
|
|
self.snapshot = snapshot
|
|
let window = NSPanel(contentRect: .zero,
|
|
styleMask: [.titled, .closable, .resizable, .fullSizeContentView, .nonactivatingPanel],
|
|
backing: .buffered, defer: false)
|
|
window.isReleasedWhenClosed = false
|
|
window.isFloatingPanel = true
|
|
window.hidesOnDeactivate = false
|
|
window.title = normalWindow.title
|
|
window.representedURL = normalWindow.representedURL
|
|
window.titleVisibility = .hidden
|
|
window.titlebarAppearsTransparent = true
|
|
window.titlebarSeparatorStyle = .none
|
|
window.appearance = normalWindow.appearance
|
|
window.isOpaque = normalWindow.isOpaque
|
|
window.backgroundColor = normalWindow.backgroundColor
|
|
window.hasShadow = normalWindow.hasShadow
|
|
window.alphaValue = normalWindow.alphaValue
|
|
window.ignoresMouseEvents = normalWindow.ignoresMouseEvents
|
|
window.acceptsMouseMovedEvents = normalWindow.acceptsMouseMovedEvents
|
|
window.level = .floating
|
|
window.collectionBehavior = [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]
|
|
window.isMovableByWindowBackground = false
|
|
window.setFrame(normalWindow.frame, display: false)
|
|
let proxy = FullScreenDelegate(owner: self, original: nil)
|
|
panelDelegate = proxy
|
|
window.delegate = proxy
|
|
compactPanel = window
|
|
for name in [NSWindow.willCloseNotification, NSWindow.didEndLiveResizeNotification] {
|
|
NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)),
|
|
name: name, object: window)
|
|
}
|
|
queueHeight = 0
|
|
setState(compact: true, transitioning: animated)
|
|
normalWindow.orderOut(nil)
|
|
if content === normalWindow.contentView { normalWindow.contentView = nil }
|
|
else { content.removeFromSuperview() }
|
|
window.contentView = content
|
|
window.minSize = NSSize(width: 160, height: 90)
|
|
window.contentMinSize = window.minSize
|
|
if snapshot.isVisible {
|
|
window.orderFrontRegardless()
|
|
if NSApp.isActive { window.makeKey() }
|
|
window.makeFirstResponder(snapshot.firstResponder)
|
|
}
|
|
fitCompactWindow(window, initial: true, animated: animated)
|
|
}
|
|
|
|
private func removeCompactPanel(returningContentTo window: NSWindow) {
|
|
guard let panel = compactPanel else { return }
|
|
NotificationCenter.default.removeObserver(self, name: nil, object: panel)
|
|
panel.delegate = nil
|
|
panelDelegate = nil
|
|
compactPanel = nil
|
|
window.title = panel.title
|
|
window.representedURL = panel.representedURL
|
|
let content = panel.contentView
|
|
panel.contentView = nil
|
|
if let content, let snapshot, let superview = snapshot.contentSuperview {
|
|
superview.addSubview(content)
|
|
content.frame = snapshot.contentFrame
|
|
} else {
|
|
window.contentView = content
|
|
}
|
|
panel.close()
|
|
}
|
|
|
|
private func restoreSnapshot(to window: NSWindow) {
|
|
guard let snapshot else { return }
|
|
let wasKey = compactPanel?.isKeyWindow ?? false
|
|
removeCompactPanel(returningContentTo: window)
|
|
window.aspectRatio = .zero
|
|
window.minSize = .zero
|
|
window.contentMinSize = .zero
|
|
window.styleMask = snapshot.styleMask
|
|
window.titleVisibility = snapshot.titleVisibility
|
|
window.titlebarAppearsTransparent = snapshot.titlebarAppearsTransparent
|
|
window.collectionBehavior = snapshot.collectionBehavior
|
|
window.level = snapshot.level
|
|
window.hidesOnDeactivate = snapshot.hidesOnDeactivate
|
|
window.isMovableByWindowBackground = snapshot.isMovableByWindowBackground
|
|
window.minSize = snapshot.minSize
|
|
window.contentMinSize = snapshot.contentMinSize
|
|
window.setFrame(snapshot.frame, display: true)
|
|
if snapshot.aspectRatio == .zero {
|
|
window.resizeIncrements = snapshot.resizeIncrements
|
|
} else {
|
|
window.aspectRatio = snapshot.aspectRatio
|
|
}
|
|
if snapshot.isVisible {
|
|
if wasKey { window.makeKeyAndOrderFront(nil) }
|
|
else { window.orderFront(nil) }
|
|
window.makeFirstResponder(snapshot.firstResponder)
|
|
}
|
|
self.snapshot = nil
|
|
onChange?()
|
|
}
|
|
|
|
private func finishSession() {
|
|
sessionActive = false
|
|
originatedFullScreen = false
|
|
wantsCompact = false
|
|
wantsFullScreen = false
|
|
setState(compact: false, transitioning: false)
|
|
}
|
|
|
|
private func animateFrame(_ frame: NSRect, of window: NSWindow, animated: Bool,
|
|
completion: @escaping @MainActor () -> Void) {
|
|
guard animated, !reduceMotion(), window.frame != frame else {
|
|
window.setFrame(frame, display: true)
|
|
completion()
|
|
return
|
|
}
|
|
frameAnimationGeneration &+= 1
|
|
let generation = frameAnimationGeneration
|
|
frameAnimating = true
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = Self.transitionDuration
|
|
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
window.animator().setFrame(frame, display: true)
|
|
} completionHandler: { [weak self, weak window] in
|
|
MainActor.assumeIsolated {
|
|
guard let self, let window, self.presentationWindow === window, self.frameAnimating,
|
|
self.frameAnimationGeneration == generation else { return }
|
|
self.frameAnimating = false
|
|
completion()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopFrameAnimation() {
|
|
guard frameAnimating else { return }
|
|
frameAnimating = false
|
|
frameAnimationGeneration &+= 1
|
|
guard let window = presentationWindow else { return }
|
|
NSAnimationContext.runAnimationGroup { context in
|
|
context.duration = 0
|
|
window.animator().setFrame(window.frame, display: true)
|
|
}
|
|
}
|
|
|
|
private func setState(compact: Bool, transitioning: Bool) {
|
|
guard isCompact != compact || isTransitioning != transitioning else { return }
|
|
isCompact = compact
|
|
isTransitioning = transitioning
|
|
onChange?()
|
|
}
|
|
|
|
private func visibleFrame(for window: NSWindow) -> NSRect {
|
|
(window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
|
|
}
|
|
|
|
private func fitCompactWindow(_ window: NSWindow, initial: Bool, animated: Bool = false) {
|
|
let visibleFrame = visibleFrame(for: window)
|
|
let usable = Self.usableFrame(visibleFrame)
|
|
let aspect = videoAspect
|
|
let savedWidth = initial ? defaults.double(forKey: Self.widthKey) : Double(window.frame.width)
|
|
let width = savedWidth.isFinite && savedWidth > 0 ? CGFloat(savedWidth) : 320
|
|
let video = Self.compactSize(aspect: videoAspect, width: width,
|
|
available: NSSize(width: usable.width, height: max(0, usable.height - queueHeight)))
|
|
var frame = NSRect(x: usable.maxX - video.width, y: usable.maxY - video.height - queueHeight,
|
|
width: video.width, height: video.height + queueHeight)
|
|
if !initial {
|
|
frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width)
|
|
frame.origin.y = max(usable.minY, min(window.frame.maxY, usable.maxY) - frame.height)
|
|
}
|
|
window.resizeIncrements = NSSize(width: 1, height: 1)
|
|
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
|
|
guard let self, let window else { return }
|
|
if self.queueHeight == 0, Self.canFitAspect(aspect, available: usable.size) {
|
|
window.aspectRatio = NSSize(width: aspect, height: 1)
|
|
}
|
|
self.fittedAspect = aspect
|
|
self.fittedVideoAspect = video.width / video.height
|
|
if initial { self.reconcile() }
|
|
}
|
|
}
|
|
|
|
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect, width: CGFloat) -> NSRect {
|
|
let usable = usableFrame(visibleFrame)
|
|
let size = compactSize(aspect: aspect, width: width, available: usable.size)
|
|
return NSRect(x: usable.maxX - size.width, y: usable.maxY - size.height, width: size.width, height: size.height)
|
|
}
|
|
|
|
nonisolated static func snappedFrame(_ frame: NSRect, visibleFrame: NSRect) -> NSRect {
|
|
guard frame.origin.x.isFinite, frame.origin.y.isFinite,
|
|
frame.width.isFinite, frame.height.isFinite, frame.width >= 0, frame.height >= 0,
|
|
validVisibleFrame(visibleFrame) else { return frame }
|
|
let usable = usableFrame(visibleFrame)
|
|
var result = frame
|
|
result.origin.x = snappedOrigin(frame.minX, lower: usable.minX, upper: usable.maxX - frame.width)
|
|
result.origin.y = snappedOrigin(frame.minY, lower: usable.minY, upper: usable.maxY - frame.height)
|
|
return result
|
|
}
|
|
|
|
nonisolated private static func snappedOrigin(_ origin: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
|
|
let nearest = abs(origin - lower) <= abs(origin - upper) ? lower : upper
|
|
return abs(origin - nearest) <= 24 ? nearest : origin
|
|
}
|
|
|
|
nonisolated private static func validVisibleFrame(_ frame: NSRect) -> Bool {
|
|
frame.origin.x.isFinite && frame.origin.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
|
|
frame.width > 0 && frame.height > 0 && frame.maxX.isFinite && frame.maxY.isFinite
|
|
}
|
|
|
|
nonisolated private static func usableFrame(_ visibleFrame: NSRect) -> NSRect {
|
|
guard validVisibleFrame(visibleFrame) else { return .zero }
|
|
return visibleFrame.insetBy(dx: min(16, visibleFrame.width / 2),
|
|
dy: min(16, visibleFrame.height / 2))
|
|
}
|
|
|
|
nonisolated private static func canFitAspect(_ aspect: Double, available: NSSize) -> Bool {
|
|
guard aspect.isFinite, aspect > 0, available.width > 0, available.height > 0 else { return false }
|
|
return aspect >= min(160, available.width) / available.height &&
|
|
aspect <= available.width / min(90, available.height)
|
|
}
|
|
|
|
nonisolated private static func compactSize(aspect: Double, width: CGFloat, available: NSSize) -> NSSize {
|
|
guard available.width > 0, available.height > 0 else { return .zero }
|
|
let aspect = aspect.isFinite && aspect > 0 ? aspect : 16.0 / 9.0
|
|
var width = min(max(width, min(160, available.width)), available.width)
|
|
if canFitAspect(aspect, available: available) {
|
|
width = min(max(width, min(90, available.height) * aspect), available.height * aspect)
|
|
return NSSize(width: width, height: width / aspect)
|
|
}
|
|
// Extreme video ratios letterbox instead of violating the compact minimum or screen bounds.
|
|
return NSSize(width: width, height: min(max(width / (16.0 / 9.0), min(90, available.height)), available.height))
|
|
}
|
|
}
|