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 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 @MainActor init(_ window: NSWindow) { 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 } } private enum FullScreenTransition { case entering, exiting } private weak var window: NSWindow? 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) { 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 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 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 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 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 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, 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: stopFrameAnimation() 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 { fitCompactWindow(window, 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: animated) window.aspectRatio = .zero window.minSize = .zero window.contentMinSize = .zero animateFrame(snapshot.frame, of: window, 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(_ window: NSWindow, animated: Bool) { // After didExitFullScreen, this is the normal frame, not the fullscreen frame. snapshot = Snapshot(window) queueHeight = 0 setState(compact: true, transitioning: animated) window.aspectRatio = .zero window.minSize = .zero window.contentMinSize = .zero // A borderless NSWindow cannot become key; retain native keyboard and resize behavior. window.styleMask.formUnion([.titled, .fullSizeContentView, .resizable]) window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.minSize = NSSize(width: 160, height: 90) window.contentMinSize = NSSize(width: 160, height: 90) var behavior = window.collectionBehavior behavior.subtract([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone, .moveToActiveSpace]) behavior.formUnion([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]) window.collectionBehavior = behavior window.level = .floating window.hidesOnDeactivate = false // Explicit drag surfaces preserve controls' mouse tracking in compact mode too. window.isMovableByWindowBackground = false fitCompactWindow(window, initial: true, animated: animated) } private func restoreSnapshot(to window: NSWindow) { guard let snapshot else { return } 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 } self.snapshot = nil } 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.window === 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 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)) } }