Files
terebi/Sources/App/CompactWindow.swift
T

486 lines
21 KiB
Swift

import AppKit
@MainActor
final class CompactWindowController: NSObject {
var onChange: (() -> Void)?
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
private(set) var isCompact = false
private(set) var isTransitioning = false
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 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)
}
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.isCompact, !owner.isTransitioning,
!sender.styleMask.contains(.fullScreen),
let layout = owner.normalVideoLayout?(),
layout.aspect.isFinite, layout.aspect > 0 else { return proposed }
// The sidebar stays fixed-width; only the video area is aspect-locked.
let chrome = 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 {
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)
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
window.isMovableByWindowBackground = true
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
// Coordinate the resize through NSWindow. Its animator proxy races SwiftUI's
// animated window sizing and can re-enter layout until AppKit aborts.
NSAnimationContext.runAnimationGroup { _ in
window.setFrame(frame, display: true, animate: 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
// Native resize animation has returned; invalidate only its pending completion.
}
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
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame, width: width)
if !initial {
frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width)
frame.origin.y = min(max(window.frame.minY, usable.minY), 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.canFitAspect(aspect, available: usable.size) {
window.aspectRatio = NSSize(width: aspect, height: 1)
}
self.fittedAspect = aspect
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))
}
}