fix: use a nonactivating compact panel across fullscreen spaces
This commit is contained in:
+109
-33
@@ -7,6 +7,7 @@ final class CompactWindowController: NSObject {
|
||||
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
|
||||
|
||||
@@ -30,9 +31,13 @@ final class CompactWindowController: NSObject {
|
||||
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) {
|
||||
init(_ window: NSWindow, contentView: NSView) {
|
||||
frame = window.frame
|
||||
minSize = window.minSize
|
||||
contentMinSize = window.contentMinSize
|
||||
@@ -45,12 +50,19 @@ final class CompactWindowController: NSObject {
|
||||
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?
|
||||
@@ -77,7 +89,7 @@ final class CompactWindowController: NSObject {
|
||||
|
||||
deinit { NotificationCenter.default.removeObserver(self) }
|
||||
|
||||
func attach(_ window: NSWindow) {
|
||||
func attach(_ window: NSWindow, contentView: NSView? = nil) {
|
||||
guard window !== self.window else { return }
|
||||
stopFrameAnimation()
|
||||
if let oldWindow = self.window, fullScreenTransition == nil,
|
||||
@@ -87,6 +99,7 @@ final class CompactWindowController: NSObject {
|
||||
restoreDelegate()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
self.window = window
|
||||
self.contentView = contentView ?? window.contentView
|
||||
snapshot = nil
|
||||
fullScreenTransition = nil
|
||||
sessionActive = false
|
||||
@@ -133,13 +146,13 @@ final class CompactWindowController: NSObject {
|
||||
func updateAspect(_ aspect: Double) {
|
||||
guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return }
|
||||
videoAspect = aspect
|
||||
guard isCompact, !isTransitioning, let window else { return }
|
||||
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 else { return nil }
|
||||
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,
|
||||
@@ -152,7 +165,7 @@ final class CompactWindowController: NSObject {
|
||||
}
|
||||
|
||||
func updateQueue(height: CGFloat, frame: NSRect) {
|
||||
guard isCompact, let window else { return }
|
||||
guard isCompact, let window = presentationWindow else { return }
|
||||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||||
queueHeight = height
|
||||
fittedVideoAspect = frame.width / (frame.height - height)
|
||||
@@ -166,7 +179,7 @@ final class CompactWindowController: NSObject {
|
||||
}
|
||||
|
||||
func snapAfterDrag() {
|
||||
guard isCompact, !isTransitioning, snappingEnabled, let window else { return }
|
||||
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) }
|
||||
}
|
||||
@@ -174,7 +187,7 @@ final class CompactWindowController: NSObject {
|
||||
@objc private func windowNotification(_ notification: Notification) {
|
||||
switch notification.name {
|
||||
case NSWindow.didEndLiveResizeNotification:
|
||||
guard isCompact, !isTransitioning, let window,
|
||||
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:
|
||||
@@ -187,7 +200,12 @@ final class CompactWindowController: NSObject {
|
||||
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
|
||||
@@ -318,7 +336,7 @@ final class CompactWindowController: NSObject {
|
||||
} else if !isCompact {
|
||||
enterCompact(window, animated: animated)
|
||||
} else {
|
||||
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) }
|
||||
if fittedAspect != videoAspect, let panel = compactPanel { fitCompactWindow(panel, initial: false) }
|
||||
setState(compact: true, transitioning: false)
|
||||
}
|
||||
return
|
||||
@@ -333,11 +351,12 @@ final class CompactWindowController: NSObject {
|
||||
}
|
||||
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
|
||||
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()
|
||||
@@ -357,33 +376,84 @@ final class CompactWindowController: NSObject {
|
||||
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])
|
||||
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.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.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.hidesOnDeactivate = false
|
||||
// Explicit drag surfaces preserve controls' mouse tracking in compact mode too.
|
||||
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
|
||||
@@ -402,7 +472,13 @@ final class CompactWindowController: NSObject {
|
||||
} 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() {
|
||||
@@ -429,7 +505,7 @@ final class CompactWindowController: NSObject {
|
||||
window.animator().setFrame(frame, display: true)
|
||||
} completionHandler: { [weak self, weak window] in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, let window, self.window === window, self.frameAnimating,
|
||||
guard let self, let window, self.presentationWindow === window, self.frameAnimating,
|
||||
self.frameAnimationGeneration == generation else { return }
|
||||
self.frameAnimating = false
|
||||
completion()
|
||||
@@ -441,7 +517,7 @@ final class CompactWindowController: NSObject {
|
||||
guard frameAnimating else { return }
|
||||
frameAnimating = false
|
||||
frameAnimationGeneration &+= 1
|
||||
guard let window else { return }
|
||||
guard let window = presentationWindow else { return }
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0
|
||||
window.animator().setFrame(window.frame, display: true)
|
||||
|
||||
@@ -93,7 +93,8 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
|
||||
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
|
||||
guard self.window === window, event.window === window,
|
||||
NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
|
||||
if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openStream).contains(chord) {
|
||||
return perform(.openStream)
|
||||
}
|
||||
@@ -127,7 +128,7 @@ extension PlayerModel {
|
||||
perform(action)
|
||||
return
|
||||
}
|
||||
if let window = event.window ?? NSApp.keyWindow,
|
||||
if let window = window ?? event.window ?? NSApp.keyWindow,
|
||||
!handleKeyEvent(event, in: window, chord: chord),
|
||||
let responder = window.firstResponder as? NSView {
|
||||
if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) }
|
||||
@@ -245,12 +246,12 @@ struct PlayerSponsorBlockMenu: View {
|
||||
|
||||
@MainActor
|
||||
struct PlayerCommands: Commands {
|
||||
@ObservedObject var model: PlayerModel
|
||||
@FocusedObject private var focusedModel: PlayerModel?
|
||||
|
||||
var body: some Commands {
|
||||
// The player has one scene; app-level Open/Quit still work before it becomes focused.
|
||||
let model = focusedModel ?? PlayerModel.shared
|
||||
let hasFocusedScene = focusedModel != nil
|
||||
// Nonactivating compact panels need not provide SwiftUI's scene focus.
|
||||
let hasFocusedScene = focusedModel != nil || model.window?.isKeyWindow == true
|
||||
let playback = model.playbackAction
|
||||
let mute = model.muteAction
|
||||
let fullscreen = model.fullscreenAction
|
||||
@@ -280,8 +281,8 @@ struct PlayerCommands: Commands {
|
||||
.disabled(!hasFocusedScene || !model.canPerform(.closeFile))
|
||||
}
|
||||
CommandGroup(replacing: .saveItem) {
|
||||
Button("Close Window") { NSApp.keyWindow?.performClose(nil) }
|
||||
.disabled(focusedModel == nil)
|
||||
Button("Close Window") { model.window?.performClose(nil) }
|
||||
.disabled(!hasFocusedScene)
|
||||
}
|
||||
CommandMenu("Playback") {
|
||||
CastDeviceMenu(model: model, icon: false)
|
||||
|
||||
@@ -241,7 +241,9 @@ final class PlayerModel: ObservableObject {
|
||||
let videoView: NSView?
|
||||
private var handle: OpaquePointer?
|
||||
private var timer: Timer?
|
||||
private weak var window: NSWindow?
|
||||
private weak var attachedWindow: NSWindow?
|
||||
weak var hostedPlayerView: NSView?
|
||||
var window: NSWindow? { compactWindow.presentationWindow ?? attachedWindow }
|
||||
private var hideDeadline: ContinuousClock.Instant?
|
||||
private var pointerInsideWindow = true
|
||||
private var keyboardNavigation = false
|
||||
@@ -329,6 +331,7 @@ final class PlayerModel: ObservableObject {
|
||||
self.compactIntent = nil
|
||||
self.fullScreenIntent = nil
|
||||
}
|
||||
self.updateWindowState()
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
@@ -1423,17 +1426,17 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func attachWindow(_ window: NSWindow) {
|
||||
guard self.window !== window else { return }
|
||||
guard !window.styleMask.contains(.nonactivatingPanel), attachedWindow !== window else { return }
|
||||
startContentHeight = 400 - (window.frame.height - window.contentLayoutRect.height)
|
||||
finishQueueTransition()
|
||||
self.window = window
|
||||
attachedWindow = window
|
||||
window.title = fileName ?? "Terebi"
|
||||
window.contentMinSize = NSSize(width: 400, height: 240)
|
||||
compactWindow.normalVideoLayout = { [weak self] in
|
||||
guard let self, self.showsQueue, self.state.has_video else { return nil }
|
||||
return (self.state.video_aspect_ratio, 260)
|
||||
}
|
||||
compactWindow.attach(window)
|
||||
compactWindow.attach(window, contentView: hostedPlayerView)
|
||||
updateWindowState()
|
||||
if fileName == nil { configureStartWindow(showing: true) }
|
||||
fitVideoWindow()
|
||||
@@ -4233,7 +4236,9 @@ private struct WindowChrome: NSViewRepresentable {
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.acceptsMouseMovedEvents = true
|
||||
window.collectionBehavior.insert(.fullScreenPrimary)
|
||||
if !window.styleMask.contains(.nonactivatingPanel) {
|
||||
window.collectionBehavior.insert(.fullScreenPrimary)
|
||||
}
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willStartLiveResizeNotification,
|
||||
NSWindow.willCloseNotification] {
|
||||
@@ -4280,8 +4285,9 @@ private struct WindowChrome: NSViewRepresentable {
|
||||
return consumed ? nil : event
|
||||
}
|
||||
Task { @MainActor [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
guard let self, let window, self.configuredWindow === window, self.window === window else { return }
|
||||
self.model.attachWindow(window)
|
||||
self.model.updateWindowState()
|
||||
self.updateButtons()
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
@@ -4359,6 +4365,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
let model = PlayerModel.shared
|
||||
guard model.isCompact || model.compactWindow.isTransitioning,
|
||||
let window = model.window else { return true }
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
return false
|
||||
}
|
||||
|
||||
// Let SwiftUI create the initial window for a Finder/document launch.
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
if urls.contains(where: { $0.scheme == "terebi-enqueue" }) {
|
||||
@@ -4368,13 +4382,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
else if let url = urls.first { PlayerModel.shared.open(url) }
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
// AppKit doesn't count the compact panel as a normal application window.
|
||||
let compact = PlayerModel.shared.compactWindow
|
||||
return !compact.isCompact && !compact.isTransitioning
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
PlayerModel.shared.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct PlayerSceneContent: NSViewRepresentable {
|
||||
let model: PlayerModel
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
// Keep SwiftUI's scene host in place; only this app-owned player host moves.
|
||||
let container = NSView()
|
||||
let player = NSHostingView(rootView: PlayerContent(model: model))
|
||||
player.sizingOptions = []
|
||||
player.frame = container.bounds
|
||||
player.autoresizingMask = [.width, .height]
|
||||
container.addSubview(player)
|
||||
model.hostedPlayerView = player
|
||||
return container
|
||||
}
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct TerebiApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@@ -4382,13 +4419,14 @@ struct TerebiApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
Window("\(model.fileName ?? "Terebi")", id: "terebi") {
|
||||
PlayerContent(model: model)
|
||||
PlayerSceneContent(model: model)
|
||||
.ignoresSafeArea()
|
||||
.focusedSceneObject(model)
|
||||
}
|
||||
.defaultSize(width: 640, height: 400)
|
||||
.windowResizability(.contentSize)
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.commands { PlayerCommands() }
|
||||
.commands { PlayerCommands(model: model) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user