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) ContentView(bridge: bridge)
.frame(minWidth: 900, minHeight: 600) .frame(minWidth: 900, minHeight: 600)
.onAppear { bridge.setup() } .onAppear { bridge.setup() }
.onDisappear { bridge.cleanup() }
} }
#if os(macOS) #if os(macOS)
.commands { .commands {

View File

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