diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 40ff2dc..2a9e165 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -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() { diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index e4d081b..f1325ae 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -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) + // 使用系统 AVPlayerView,Apple 内部处理所有 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 diff --git a/Sources/VideoPlayerView.swift b/Sources/VideoPlayerView.swift index 1b115ab..f340921 100644 --- a/Sources/VideoPlayerView.swift +++ b/Sources/VideoPlayerView.swift @@ -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