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() }
}