Exit fullscreen before dismissing macOS player window

Ordering out a window in native fullscreen skips the exit transition and
strands its black fullscreen space on screen. Route all dismissal paths
(stop/collapse, PiP hide, window close) through exitFullScreenIfNeeded,
which leaves fullscreen first and defers hiding until the transition
completes, with a timeout fallback if AppKit refuses the exit.
This commit is contained in:
Arkadiusz Fal
2026-07-12 21:01:15 +02:00
parent ab5163e29f
commit 66f7602d08

View File

@@ -250,14 +250,18 @@ final class ExpandedPlayerWindowManager: NSObject {
} }
} }
if animated { exitFullScreenIfNeeded(window) {
NSAnimationContext.runAnimationGroup({ context in Task { @MainActor in
context.duration = 0.2 if animated {
context.timingFunction = CAMediaTimingFunction(name: .easeIn) NSAnimationContext.runAnimationGroup({ context in
window.animator().alphaValue = 0 context.duration = 0.2
}, completionHandler: hideWindow) context.timingFunction = CAMediaTimingFunction(name: .easeIn)
} else { window.animator().alphaValue = 0
hideWindow() }, completionHandler: hideWindow)
} else {
hideWindow()
}
}
} }
} else { } else {
// PiP is not active - fully clean up the window // PiP is not active - fully clean up the window
@@ -283,18 +287,48 @@ final class ExpandedPlayerWindowManager: NSObject {
} }
} }
if animated { exitFullScreenIfNeeded(window) {
NSAnimationContext.runAnimationGroup({ context in Task { @MainActor in
context.duration = 0.2 if animated {
context.timingFunction = CAMediaTimingFunction(name: .easeIn) NSAnimationContext.runAnimationGroup({ context in
window.animator().alphaValue = 0 context.duration = 0.2
}, completionHandler: cleanup) context.timingFunction = CAMediaTimingFunction(name: .easeIn)
} else { window.animator().alphaValue = 0
cleanup() }, completionHandler: cleanup)
} else {
cleanup()
}
}
} }
} }
} }
/// Runs `completion` once `window` is out of native fullscreen. Ordering
/// out a fullscreen window skips the exit transition and strands its (now
/// empty, black) fullscreen space on screen, so every dismissal path must
/// leave fullscreen before hiding the window.
private func exitFullScreenIfNeeded(_ window: NSWindow, completion: @escaping @Sendable () -> Void) {
guard window.styleMask.contains(.fullScreen) else {
completion()
return
}
let waiter = FullScreenExitWaiter()
waiter.observer = NotificationCenter.default.addObserver(
forName: NSWindow.didExitFullScreenNotification,
object: window,
queue: .main
) { _ in
waiter.finish(completion)
}
// Fallback: if AppKit refuses the exit (e.g. mid-transition), don't
// leave the window stranded on screen forever.
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
waiter.finish(completion)
}
window.toggleFullScreen(nil)
}
/// Updates the window level based on floating preference. /// Updates the window level based on floating preference.
/// Call this when the user changes the player mode setting. /// Call this when the user changes the player mode setting.
func updateWindowLevel(floating: Bool) { func updateWindowLevel(floating: Bool) {
@@ -627,6 +661,24 @@ final class ExpandedPlayerWindowManager: NSObject {
} }
} }
/// One-shot gate shared by the fullscreen-exit notification and its timeout
/// fallback: whichever fires first runs the completion, the other is a no-op.
/// Both fire on the main thread.
private final class FullScreenExitWaiter: @unchecked Sendable {
var observer: NSObjectProtocol?
private var completed = false
func finish(_ completion: @Sendable () -> Void) {
guard !completed else { return }
completed = true
if let observer {
NotificationCenter.default.removeObserver(observer)
self.observer = nil
}
completion()
}
}
// MARK: - NSWindowDelegate // MARK: - NSWindowDelegate
extension ExpandedPlayerWindowManager: NSWindowDelegate { extension ExpandedPlayerWindowManager: NSWindowDelegate {
@@ -682,23 +734,26 @@ extension ExpandedPlayerWindowManager: NSWindowDelegate {
// proper cleanup of render resources // proper cleanup of render resources
appEnvironment?.playerService.stop() appEnvironment?.playerService.stop()
// Clean up window
sender.delegate = nil
sender.contentViewController = nil
sender.orderOut(nil)
// Update navigation state // Update navigation state
// Set collapsing first so mini player shows video immediately // Set collapsing first so mini player shows video immediately
let navigationCoordinator = appEnvironment?.navigationCoordinator let navigationCoordinator = appEnvironment?.navigationCoordinator
navigationCoordinator?.isPlayerCollapsing = true navigationCoordinator?.isPlayerCollapsing = true
navigationCoordinator?.isPlayerExpanded = false navigationCoordinator?.isPlayerExpanded = false
// Unlike hide(), this path has no animation completion to reset the // Clean up window, leaving native fullscreen first (orderOut on a
// flag. Left stuck true, the mini capsule mounts its video container // fullscreen window strands its black fullscreen space on screen)
// forever and hijacks the shared render view on the next expand exitFullScreenIfNeeded(sender) {
// (player window stays black while the capsule renders the video). Task { @MainActor in
Task { @MainActor in sender.delegate = nil
navigationCoordinator?.isPlayerCollapsing = false sender.contentViewController = nil
sender.orderOut(nil)
// Unlike hide(), this path has no animation completion to reset the
// flag. Left stuck true, the mini capsule mounts its video container
// forever and hijacks the shared render view on the next expand
// (player window stays black while the capsule renders the video).
navigationCoordinator?.isPlayerCollapsing = false
}
} }
} }
// Return false - we've already hidden the window with orderOut // Return false - we've already hidden the window with orderOut