fix: use AVPlayerView (AVKit) instead of custom AVPlayerLayer

Root cause: custom AVPlayerLayer lifecycle management causes CoreMedia
FigNotificationCenter weak listener race condition. Apple's AVPlayerView
handles all internal teardown correctly.

Changes:
- VideoPlayerView.swift: NSViewRepresentable wrapping AVPlayerView (macOS)
  and UIViewControllerRepresentable wrapping AVPlayerViewController (iOS)
- PlayerBridge.swift: fullscreen uses AVPlayerView, removed
  FullscreenPlayerView class entirely
- Fullscreen interaction via NSEvent.addLocalMonitorForEvents:
  double-click exits, single-click toggles play/pause, Escape exits
- cleanup() now closes fullscreen window and removes event monitor
- Removed ExitFullscreen NotificationCenter listener from MiniPlayerApp
This commit is contained in:
yumoqing 2026-06-22 01:16:49 +08:00
parent 6bbd86006a
commit 85f5699878
3 changed files with 72 additions and 120 deletions

View File

@ -139,12 +139,7 @@ struct ContentView: View {
toolbarVisible = false
}
}
// 退 async SwiftUI NSWindow
.onReceive(NotificationCenter.default.publisher(for: .init("ExitFullscreen"))) { _ in
DispatchQueue.main.async { [bridge] in
bridge.toggleFullscreen()
}
}
}
private func touchInteraction() {

View File

@ -1,5 +1,6 @@
import SwiftUI
import AVFoundation
import AVKit
#if os(macOS)
import AppKit
#endif
@ -80,6 +81,7 @@ final class PlayerBridge: ObservableObject {
#if os(macOS)
private var fullscreenWindow: NSWindow?
private var playlistWindow: NSWindow?
private var fullscreenEventMonitor: Any?
#endif
// MARK: -
@ -279,9 +281,13 @@ final class PlayerBridge: ObservableObject {
func toggleFullscreen() {
#if os(macOS)
if let fw = fullscreenWindow {
// AVPlayerLayer player autorelease pool drain
// CoreMedia 线 sFigNotificationCenterWeakListenerLinks
if let playerView = fw.contentView as? FullscreenPlayerView {
//
if let monitor = fullscreenEventMonitor {
NSEvent.removeMonitor(monitor)
fullscreenEventMonitor = nil
}
// player
if let playerView = fw.contentView as? AVPlayerView {
playerView.player = nil
}
fw.close()
@ -298,14 +304,43 @@ final class PlayerBridge: ObservableObject {
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
win.hasShadow = false
let playerView = FullscreenPlayerView(frame: screen.frame)
// 使 AVPlayerViewApple CoreMedia
let playerView = AVPlayerView(frame: screen.frame)
playerView.player = player
playerView.controlsStyle = .none
playerView.videoGravity = .resizeAspect
win.contentView = playerView
win.makeKeyAndOrderFront(nil)
win.makeFirstResponder(playerView)
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()
} else {
self.player.play()
}
return nil
}
} else if event.type == .keyDown {
if event.keyCode == 53 { // Escape
self.toggleFullscreen()
return nil
}
}
return event
}
#endif
}
@ -401,6 +436,20 @@ final class PlayerBridge: ObservableObject {
isTearingDown = true
player.pause()
#if os(macOS)
//
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
}
#endif
// 1. Timer
updateTimer?.invalidate()
updateTimer = nil
@ -413,9 +462,6 @@ final class PlayerBridge: ObservableObject {
NotificationCenter.default.removeObserver(token)
endObserverToken = nil
}
// 4. replaceCurrentItem(with: nil)
// playerItem player CoreMedia
}
deinit {
@ -424,55 +470,3 @@ final class PlayerBridge: ObservableObject {
updateTimer?.invalidate()
}
}
// MARK: - NSView AppKit SwiftUI
#if os(macOS)
import AppKit
class FullscreenPlayerView: NSView {
// 使 makeBackingLayer AppKit AVPlayerLayer
override func makeBackingLayer() -> CALayer { AVPlayerLayer() }
private var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
var player: AVPlayer? {
get { playerLayer.player }
set { playerLayer.player = newValue }
}
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
playerLayer.videoGravity = .resizeAspect
}
required init?(coder: NSCoder) { fatalError() }
override var acceptsFirstResponder: Bool { true }
// player
// CoreMedia 线 autorelease pool drain
override func viewWillMove(toWindow newWindow: NSWindow?) {
if newWindow == nil {
playerLayer.player = nil
}
super.viewWillMove(toWindow: newWindow)
}
override func mouseDown(with event: NSEvent) {
if event.clickCount == 2 {
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
} else {
if let p = player {
if p.timeControlStatus == .playing { p.pause() } else { p.play() }
}
}
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { // Escape
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
}
}
}
#endif

View File

@ -1,37 +1,26 @@
import SwiftUI
import AVFoundation
import AVKit
// MARK: - AVPlayer
// MARK: - AVPlayer 使 AVPlayerView / AVPlayerViewController
#if os(iOS)
import UIKit
struct VideoPlayerRepresentable: UIViewRepresentable {
struct VideoPlayerRepresentable: UIViewControllerRepresentable {
let player: AVPlayer
func makeUIView(context: Context) -> PlayerUIView {
let view = PlayerUIView()
view.player = player
return view
func makeUIViewController(context: Context) -> AVPlayerViewController {
let vc = AVPlayerViewController()
vc.player = player
vc.showsPlaybackControls = false
vc.videoGravity = .resizeAspect
return vc
}
func updateUIView(_ uiView: PlayerUIView, context: Context) {
uiView.player = player
}
}
class PlayerUIView: UIView {
override static var layerClass: AnyClass { AVPlayerLayer.self }
var player: AVPlayer? {
get { (layer as? AVPlayerLayer)?.player }
set { (layer as? AVPlayerLayer)?.player = newValue }
}
override var contentMode: UIView.ContentMode {
get { (layer as? AVPlayerLayer)?.videoGravity == .resizeAspectFill ? .scaleAspectFill : .scaleAspectFit }
set {
(layer as? AVPlayerLayer)?.videoGravity = newValue == .scaleAspectFill ? .resizeAspectFill : .resizeAspect
func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {
if vc.player !== player {
vc.player = player
}
}
}
@ -42,44 +31,18 @@ import AppKit
struct VideoPlayerRepresentable: NSViewRepresentable {
let player: AVPlayer
func makeCoordinator() -> Coordinator { Coordinator() }
class Coordinator {
weak var view: PlayerNSView?
}
func makeNSView(context: Context) -> PlayerNSView {
let view = PlayerNSView()
func makeNSView(context: Context) -> AVPlayerView {
let view = AVPlayerView()
view.player = player
context.coordinator.view = view
view.controlsStyle = .none
view.videoGravity = .resizeAspect
return view
}
func updateNSView(_ nsView: PlayerNSView, context: Context) {
// player Coordinator
func updateNSView(_ nsView: AVPlayerView, context: Context) {
if nsView.player !== player {
nsView.player = player
}
}
}
class PlayerNSView: NSView {
// 使 makeBackingLayer AppKit AVPlayerLayer backing layer
override func makeBackingLayer() -> CALayer { AVPlayerLayer() }
private var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
playerLayer.videoGravity = .resizeAspect
}
required init?(coder: NSCoder) { fatalError() }
var player: AVPlayer? {
get { playerLayer.player }
set { playerLayer.player = newValue }
}
}
#endif