feat: add same-window compact playback with magnetic snapping
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class CompactWindowController: NSObject {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var isCompact = false
|
||||
private(set) var isTransitioning = false
|
||||
|
||||
var snappingEnabled: Bool {
|
||||
didSet {
|
||||
guard snappingEnabled != oldValue else { return }
|
||||
defaults.set(snappingEnabled, forKey: "compactSnappingEnabled")
|
||||
onChange?()
|
||||
}
|
||||
}
|
||||
|
||||
private struct Snapshot {
|
||||
let frame: NSRect
|
||||
let minSize: NSSize
|
||||
let contentMinSize: NSSize
|
||||
let aspectRatio: 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
|
||||
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 let defaults: UserDefaults
|
||||
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?
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
defaults.register(defaults: ["compactSnappingEnabled": true])
|
||||
snappingEnabled = defaults.bool(forKey: "compactSnappingEnabled")
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit { NotificationCenter.default.removeObserver(self) }
|
||||
|
||||
func attach(_ window: NSWindow) {
|
||||
guard window !== self.window else { return }
|
||||
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] {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)),
|
||||
name: name, object: window)
|
||||
}
|
||||
}
|
||||
|
||||
func toggle(aspect: Double) {
|
||||
if sessionActive && wantsCompact {
|
||||
restore()
|
||||
return
|
||||
}
|
||||
guard let window, aspect.isFinite, aspect > 0 else { return }
|
||||
if !sessionActive {
|
||||
sessionActive = true
|
||||
originatedFullScreen = window.styleMask.contains(.fullScreen) || fullScreenTransition != nil
|
||||
}
|
||||
videoAspect = aspect
|
||||
wantsCompact = true
|
||||
wantsFullScreen = false
|
||||
reconcile()
|
||||
}
|
||||
|
||||
func restore(resumeFullScreen: Bool = true) {
|
||||
guard sessionActive else { return }
|
||||
wantsCompact = false
|
||||
wantsFullScreen = resumeFullScreen && originatedFullScreen
|
||||
reconcile()
|
||||
}
|
||||
|
||||
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.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:
|
||||
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?
|
||||
|
||||
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 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() {
|
||||
guard let window, fullScreenTransition == nil 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)
|
||||
} 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 snapshot != nil {
|
||||
setState(compact: isCompact, transitioning: true)
|
||||
restoreSnapshot(to: window)
|
||||
setState(compact: false, transitioning: wantsFullScreen)
|
||||
}
|
||||
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) {
|
||||
setState(compact: false, transitioning: true)
|
||||
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
|
||||
snapshot = Snapshot(window)
|
||||
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)
|
||||
setState(compact: true, transitioning: false)
|
||||
}
|
||||
|
||||
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)
|
||||
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 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) {
|
||||
let visibleFrame = visibleFrame(for: window)
|
||||
let usable = Self.usableFrame(visibleFrame)
|
||||
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame)
|
||||
if !initial {
|
||||
frame.size = Self.compactSize(aspect: videoAspect, width: window.frame.width, available: usable.size)
|
||||
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.aspectRatio = .zero
|
||||
window.setFrame(frame, display: true)
|
||||
if Self.canFitAspect(videoAspect, available: usable.size) {
|
||||
window.aspectRatio = NSSize(width: videoAspect, height: 1)
|
||||
}
|
||||
fittedAspect = videoAspect
|
||||
}
|
||||
|
||||
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect) -> NSRect {
|
||||
let usable = usableFrame(visibleFrame)
|
||||
let size = compactSize(aspect: aspect, width: 320, available: usable.size)
|
||||
return NSRect(x: usable.maxX - size.width, y: usable.minY, 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user