diff --git a/MiniPlayer.app/Contents/Info.plist b/MiniPlayer.app/Contents/Info.plist new file mode 100644 index 0000000..a822712 --- /dev/null +++ b/MiniPlayer.app/Contents/Info.plist @@ -0,0 +1,12 @@ + + + + + CFBundleNameMiniPlayer + CFBundleIdentifiercom.example.MiniPlayer + CFBundleVersion1 + CFBundleShortVersionString1.0 + CFBundleExecutableMiniPlayer + CFBundlePackageTypeAPPL + + diff --git a/MiniPlayer.app/Contents/MacOS/MiniPlayer b/MiniPlayer.app/Contents/MacOS/MiniPlayer new file mode 100755 index 0000000..5947936 Binary files /dev/null and b/MiniPlayer.app/Contents/MacOS/MiniPlayer differ diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index ecba1d5..870bd28 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -1,6 +1,5 @@ import SwiftUI import AVFoundation -import Combine #if os(macOS) import AppKit #endif @@ -69,12 +68,14 @@ final class PlayerBridge: ObservableObject { // MARK: - 内部状态 let player = AVPlayer() - var cancellables = Set() - private var timeObserver: Any? + // 用纯 Swift Timer 代替 addPeriodicTimeObserver + // addPeriodicTimeObserver 内部的 FigNotificationCenter weak listener 机制 + // 与 autorelease pool drain 存在竞态,导致双重释放野指针崩溃 + private var updateTimer: Timer? private var itemStatusObserver: NSKeyValueObservation? - private var playbackStatusObserver: NSKeyValueObservation? private var endObserverToken: NSObjectProtocol? private var preferredTrackIndex: Int = 0 + private var isTearingDown = false #if os(macOS) private var fullscreenWindow: NSWindow? @@ -86,7 +87,6 @@ final class PlayerBridge: ObservableObject { player.volume = volume setupTimeObserver() - setupPlaybackStatusObserver() setupEndObserver() } @@ -138,9 +138,10 @@ final class PlayerBridge: ObservableObject { currentTimeText = "00:00" totalTimeText = "00:00" + // KVO 可能从非主线程回调,用 DispatchQueue.main.async itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in if pi.status == .readyToPlay { - Task { @MainActor in self?.loadDuration(pi) } + DispatchQueue.main.async { self?.loadDuration(pi) } } } @@ -150,14 +151,17 @@ final class PlayerBridge: ObservableObject { // MARK: - 播放结束 private func setupEndObserver() { + // queue: .main → 回调已在主线程,直接调用,不创建 Task endObserverToken = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in self?.onPlaybackEnded() } + guard let self = self else { return } + Task { @MainActor in self.onPlaybackEnded() } } } private func onPlaybackEnded() { + guard !isTearingDown else { return } switch repeatMode { case .none: if currentIndex < queue.count - 1 { playNext() } else { isPlaying = false } @@ -168,20 +172,28 @@ final class PlayerBridge: ObservableObject { } } - // MARK: - 时间更新 + // MARK: - 时间更新(纯 Timer,不经过 CoreMedia) private func setupTimeObserver() { - let interval = CMTime(seconds: 0.5, preferredTimescale: 600) - timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in - Task { @MainActor in self?.updateTimeDisplay(time: time) } + updateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + // Timer.scheduledTimer 的回调在添加它的 RunLoop 线程上 + // 由于 PlayerBridge 是 @MainActor 且 setup() 在主线程调用, + // Timer 被添加到主线程 RunLoop + // 但 Timer 回调不是 @MainActor,所以需要 dispatch + DispatchQueue.main.async { + guard let self = self, !self.isTearingDown else { return } + self.updateTimeDisplay() + } } } - private func updateTimeDisplay(time: CMTime) { - let current = time.seconds - var total = player.currentItem?.duration.seconds ?? 0 + private func updateTimeDisplay() { + // 只用 player.currentTime() 和 cachedDuration, + // 不访问 item.duration(会触发 CoreMedia FigNotificationCenter 竞态) + let current = player.currentTime().seconds + let total = cachedDuration - if !total.isFinite || total <= 0 { total = cachedDuration } - else { cachedDuration = total } + // 更新播放状态(替代 KVO,避免后台线程竞态) + isPlaying = (player.rate > 0 && player.error == nil) currentTimeText = formatTime(current) if total.isFinite && total > 0 { @@ -281,12 +293,10 @@ final class PlayerBridge: ObservableObject { win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] win.hasShadow = false - // 使用纯 NSView + AVPlayerLayer,避免 SwiftUI 桥接崩溃 let playerView = FullscreenPlayerView(frame: screen.frame) playerView.player = player win.contentView = playerView win.makeKeyAndOrderFront(nil) - // 确保键盘事件(Escape退出)能被接收 win.makeFirstResponder(playerView) NSApp.activate(ignoringOtherApps: true) fullscreenWindow = win @@ -344,7 +354,6 @@ final class PlayerBridge: ObservableObject { // MARK: - 播放列表窗口 @Published var showPlaylistSheet = false - // 自动隐藏:非 @Published,避免 onHover 触发视图重建导致崩溃 var lastInteraction = Date() var isInteracting = false @@ -378,32 +387,36 @@ final class PlayerBridge: ObservableObject { } } - // MARK: - 播放状态 - private func setupPlaybackStatusObserver() { - playbackStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] p, _ in - let playing = (p.timeControlStatus == .playing) - Task { @MainActor in self?.isPlaying = playing } - } - } + // MARK: - 播放状态(已改为在 updateTimeDisplay 中检查 player.rate) - /// 主动清理所有观察者,在 app 退出前调用 + // MARK: - 清理 + /// 主动清理所有观察者,在 view onDisappear 时调用 + /// 必须在 PlayerBridge 被释放之前调用 func cleanup() { + isTearingDown = true player.pause() - if let obs = timeObserver { player.removeTimeObserver(obs); timeObserver = nil } + + // 1. 先停 Timer + updateTimer?.invalidate() + updateTimer = nil + + // 2. 移除 KVO 观察者(阻止后续回调) itemStatusObserver?.invalidate(); itemStatusObserver = nil - playbackStatusObserver?.invalidate(); playbackStatusObserver = nil - if let token = endObserverToken { NotificationCenter.default.removeObserver(token); endObserverToken = nil } - // 注意:不调用 replaceCurrentItem(with: nil) + + // 3. 移除通知观察者 + if let token = endObserverToken { + NotificationCenter.default.removeObserver(token) + endObserverToken = nil + } + + // 4. 不调用 replaceCurrentItem(with: nil) // 让 playerItem 随 player 自然释放,避免 CoreMedia 内部状态不一致 } deinit { - if let obs = timeObserver { player.removeTimeObserver(obs) } - itemStatusObserver?.invalidate() - playbackStatusObserver?.invalidate() - if let token = endObserverToken { NotificationCenter.default.removeObserver(token) } - player.pause() - player.replaceCurrentItem(with: nil) + // deinit 是 nonisolated,只做最小安全清理 + // 大部分清理应在 cleanup() 中完成 + updateTimer?.invalidate() } } @@ -412,17 +425,17 @@ final class PlayerBridge: ObservableObject { import AppKit class FullscreenPlayerView: NSView { + private let playerLayer = AVPlayerLayer() + var player: AVPlayer? { - get { (layer as? AVPlayerLayer)?.player } - set { (layer as? AVPlayerLayer)?.player = newValue } + get { playerLayer.player } + set { playerLayer.player = newValue } } override init(frame: NSRect) { super.init(frame: frame) wantsLayer = true - let playerLayer = AVPlayerLayer() playerLayer.videoGravity = .resizeAspect - layer = playerLayer } required init?(coder: NSCoder) { fatalError() } @@ -431,15 +444,16 @@ class FullscreenPlayerView: NSView { override func layout() { super.layout() - (layer as? AVPlayerLayer)?.frame = bounds + if playerLayer.superlayer !== layer { + layer?.addSublayer(playerLayer) + } + playerLayer.frame = bounds } 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() } }