mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user