fix: persistent fullscreen window — never destroy AVPlayerView

Root cause: every fullscreen exit destroyed the AVPlayerView, triggering
CoreMedia's FigNotificationCenterRemoveWeakListeners which races with
the main thread's autorelease pool drain. No amount of nil-ing the
player reference or delaying the close could prevent this race.

Solution: the fullscreen NSWindow + AVPlayerView are created ONCE on
first use and never destroyed. Toggle uses orderFront/orderOut instead
of makeKeyAndOrderFront/close. The AVPlayerView stays alive for the
entire app lifetime, so CoreMedia teardown never happens during toggle.

Also:
- Removed onDisappear { bridge.cleanup() } — cleanup during app
  termination races with autorelease pool drain. Process exit handles
  all teardown correctly.
- Double-click exit uses DispatchQueue.main.async to avoid calling
  toggleFullscreen from inside the event monitor callback.
- isReleasedWhenClosed = false on fullscreen window
This commit is contained in:
yumoqing 2026-06-22 09:01:04 +08:00
parent 85f5699878
commit cae63c6588
2 changed files with 46 additions and 52 deletions

View File

@ -11,7 +11,6 @@ struct MiniPlayerApp: App {
ContentView(bridge: bridge)
.frame(minWidth: 900, minHeight: 600)
.onAppear { bridge.setup() }
.onDisappear { bridge.cleanup() }
}
#if os(macOS)
.commands {

View File

@ -278,68 +278,68 @@ final class PlayerBridge: ObservableObject {
}
// MARK: -
// orderFront/orderOut
// AVPlayerView CoreMedia weak listener
func toggleFullscreen() {
#if os(macOS)
if let fw = fullscreenWindow {
//
if isFullscreen {
// 退
fullscreenWindow?.orderOut(nil)
isFullscreen = false
if let monitor = fullscreenEventMonitor {
NSEvent.removeMonitor(monitor)
fullscreenEventMonitor = nil
}
// player
if let playerView = fw.contentView as? AVPlayerView {
playerView.player = nil
}
fw.close()
fullscreenWindow = nil
isFullscreen = false
NSApp.activate(ignoringOtherApps: true)
return
}
guard let screen = NSScreen.main else { return }
let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false)
win.level = .screenSaver
win.backgroundColor = .black
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
win.hasShadow = false
// AVPlayerView
if fullscreenWindow == nil {
guard let screen = NSScreen.main else { return }
let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false)
win.level = .screenSaver
win.backgroundColor = .black
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
win.hasShadow = false
win.isReleasedWhenClosed = false
let playerView = AVPlayerView(frame: screen.frame)
playerView.player = player
playerView.controlsStyle = .none
playerView.videoGravity = .resizeAspect
win.contentView = playerView
fullscreenWindow = win
}
// 使 AVPlayerViewApple CoreMedia
let playerView = AVPlayerView(frame: screen.frame)
playerView.player = player
playerView.controlsStyle = .none
playerView.videoGravity = .resizeAspect
win.contentView = playerView
win.makeKeyAndOrderFront(nil)
fullscreenWindow?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
fullscreenWindow = win
isFullscreen = true
// 退/Escape退
fullscreenEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .keyDown]) { [weak self] event in
guard let self = self, let fw = self.fullscreenWindow else { return event }
//
if event.window !== fw { return event }
if event.type == .leftMouseDown {
if event.clickCount >= 2 {
self.toggleFullscreen()
return nil //
} else {
if self.player.timeControlStatus == .playing {
self.player.pause()
// 退/Escape退
if fullscreenEventMonitor == nil {
fullscreenEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .keyDown]) { [weak self] event in
guard let self = self, self.isFullscreen else { return event }
guard event.window === self.fullscreenWindow else { return event }
if event.type == .leftMouseDown {
if event.clickCount >= 2 {
DispatchQueue.main.async { self.toggleFullscreen() }
return nil
} else {
self.player.play()
if self.player.timeControlStatus == .playing {
self.player.pause()
} else {
self.player.play()
}
return nil
}
} else if event.type == .keyDown && event.keyCode == 53 {
DispatchQueue.main.async { self.toggleFullscreen() }
return nil
}
} else if event.type == .keyDown {
if event.keyCode == 53 { // Escape
self.toggleFullscreen()
return nil
}
return event
}
return event
}
#endif
}
@ -437,24 +437,19 @@ final class PlayerBridge: ObservableObject {
player.pause()
#if os(macOS)
//
// teardown
if let monitor = fullscreenEventMonitor {
NSEvent.removeMonitor(monitor)
fullscreenEventMonitor = nil
}
//
if let fw = fullscreenWindow {
if let pv = fw.contentView as? AVPlayerView { pv.player = nil }
fw.close()
fullscreenWindow = nil
}
fullscreenWindow?.orderOut(nil)
#endif
// 1. Timer
updateTimer?.invalidate()
updateTimer = nil
// 2. KVO
// 2. KVO
itemStatusObserver?.invalidate(); itemStatusObserver = nil
// 3.