fix: eliminate CoreMedia FigNotificationCenter race condition causing SIGSEGV
- Remove KVO on player.timeControlStatus (fires from CoreMedia bg threads) - Stop accessing item.duration in timer callback (triggers FigNotificationCenter weak listener ops) - Check player.rate in timer callback instead for isPlaying state - Remove replaceCurrentItem(nil) from cleanup to prevent CoreMedia state inconsistency - Use cachedDuration exclusively in time display updates - Fix actor isolation warning in endObserver callback
This commit is contained in:
parent
f14d47b5c8
commit
c336066198
12
MiniPlayer.app/Contents/Info.plist
Normal file
12
MiniPlayer.app/Contents/Info.plist
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleName</key><string>MiniPlayer</string>
|
||||||
|
<key>CFBundleIdentifier</key><string>com.example.MiniPlayer</string>
|
||||||
|
<key>CFBundleVersion</key><string>1</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||||
|
<key>CFBundleExecutable</key><string>MiniPlayer</string>
|
||||||
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
MiniPlayer.app/Contents/MacOS/MiniPlayer
Executable file
BIN
MiniPlayer.app/Contents/MacOS/MiniPlayer
Executable file
Binary file not shown.
@ -1,6 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Combine
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
@ -69,12 +68,14 @@ final class PlayerBridge: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - 内部状态
|
// MARK: - 内部状态
|
||||||
let player = AVPlayer()
|
let player = AVPlayer()
|
||||||
var cancellables = Set<AnyCancellable>()
|
// 用纯 Swift Timer 代替 addPeriodicTimeObserver
|
||||||
private var timeObserver: Any?
|
// addPeriodicTimeObserver 内部的 FigNotificationCenter weak listener 机制
|
||||||
|
// 与 autorelease pool drain 存在竞态,导致双重释放野指针崩溃
|
||||||
|
private var updateTimer: Timer?
|
||||||
private var itemStatusObserver: NSKeyValueObservation?
|
private var itemStatusObserver: NSKeyValueObservation?
|
||||||
private var playbackStatusObserver: NSKeyValueObservation?
|
|
||||||
private var endObserverToken: NSObjectProtocol?
|
private var endObserverToken: NSObjectProtocol?
|
||||||
private var preferredTrackIndex: Int = 0
|
private var preferredTrackIndex: Int = 0
|
||||||
|
private var isTearingDown = false
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private var fullscreenWindow: NSWindow?
|
private var fullscreenWindow: NSWindow?
|
||||||
@ -86,7 +87,6 @@ final class PlayerBridge: ObservableObject {
|
|||||||
player.volume = volume
|
player.volume = volume
|
||||||
|
|
||||||
setupTimeObserver()
|
setupTimeObserver()
|
||||||
setupPlaybackStatusObserver()
|
|
||||||
setupEndObserver()
|
setupEndObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,9 +138,10 @@ final class PlayerBridge: ObservableObject {
|
|||||||
currentTimeText = "00:00"
|
currentTimeText = "00:00"
|
||||||
totalTimeText = "00:00"
|
totalTimeText = "00:00"
|
||||||
|
|
||||||
|
// KVO 可能从非主线程回调,用 DispatchQueue.main.async
|
||||||
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||||||
if pi.status == .readyToPlay {
|
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: - 播放结束
|
// MARK: - 播放结束
|
||||||
private func setupEndObserver() {
|
private func setupEndObserver() {
|
||||||
|
// queue: .main → 回调已在主线程,直接调用,不创建 Task
|
||||||
endObserverToken = NotificationCenter.default.addObserver(
|
endObserverToken = NotificationCenter.default.addObserver(
|
||||||
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
|
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
Task { @MainActor in self?.onPlaybackEnded() }
|
guard let self = self else { return }
|
||||||
|
Task { @MainActor in self.onPlaybackEnded() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func onPlaybackEnded() {
|
private func onPlaybackEnded() {
|
||||||
|
guard !isTearingDown else { return }
|
||||||
switch repeatMode {
|
switch repeatMode {
|
||||||
case .none:
|
case .none:
|
||||||
if currentIndex < queue.count - 1 { playNext() } else { isPlaying = false }
|
if currentIndex < queue.count - 1 { playNext() } else { isPlaying = false }
|
||||||
@ -168,20 +172,28 @@ final class PlayerBridge: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 时间更新
|
// MARK: - 时间更新(纯 Timer,不经过 CoreMedia)
|
||||||
private func setupTimeObserver() {
|
private func setupTimeObserver() {
|
||||||
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
// Timer.scheduledTimer 的回调在添加它的 RunLoop 线程上
|
||||||
Task { @MainActor in self?.updateTimeDisplay(time: time) }
|
// 由于 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) {
|
private func updateTimeDisplay() {
|
||||||
let current = time.seconds
|
// 只用 player.currentTime() 和 cachedDuration,
|
||||||
var total = player.currentItem?.duration.seconds ?? 0
|
// 不访问 item.duration(会触发 CoreMedia FigNotificationCenter 竞态)
|
||||||
|
let current = player.currentTime().seconds
|
||||||
|
let total = cachedDuration
|
||||||
|
|
||||||
if !total.isFinite || total <= 0 { total = cachedDuration }
|
// 更新播放状态(替代 KVO,避免后台线程竞态)
|
||||||
else { cachedDuration = total }
|
isPlaying = (player.rate > 0 && player.error == nil)
|
||||||
|
|
||||||
currentTimeText = formatTime(current)
|
currentTimeText = formatTime(current)
|
||||||
if total.isFinite && total > 0 {
|
if total.isFinite && total > 0 {
|
||||||
@ -281,12 +293,10 @@ final class PlayerBridge: ObservableObject {
|
|||||||
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
win.hasShadow = false
|
win.hasShadow = false
|
||||||
|
|
||||||
// 使用纯 NSView + AVPlayerLayer,避免 SwiftUI 桥接崩溃
|
|
||||||
let playerView = FullscreenPlayerView(frame: screen.frame)
|
let playerView = FullscreenPlayerView(frame: screen.frame)
|
||||||
playerView.player = player
|
playerView.player = player
|
||||||
win.contentView = playerView
|
win.contentView = playerView
|
||||||
win.makeKeyAndOrderFront(nil)
|
win.makeKeyAndOrderFront(nil)
|
||||||
// 确保键盘事件(Escape退出)能被接收
|
|
||||||
win.makeFirstResponder(playerView)
|
win.makeFirstResponder(playerView)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
fullscreenWindow = win
|
fullscreenWindow = win
|
||||||
@ -344,7 +354,6 @@ final class PlayerBridge: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - 播放列表窗口
|
// MARK: - 播放列表窗口
|
||||||
@Published var showPlaylistSheet = false
|
@Published var showPlaylistSheet = false
|
||||||
// 自动隐藏:非 @Published,避免 onHover 触发视图重建导致崩溃
|
|
||||||
var lastInteraction = Date()
|
var lastInteraction = Date()
|
||||||
var isInteracting = false
|
var isInteracting = false
|
||||||
|
|
||||||
@ -378,32 +387,36 @@ final class PlayerBridge: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 播放状态
|
// MARK: - 播放状态(已改为在 updateTimeDisplay 中检查 player.rate)
|
||||||
private func setupPlaybackStatusObserver() {
|
|
||||||
playbackStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] p, _ in
|
|
||||||
let playing = (p.timeControlStatus == .playing)
|
|
||||||
Task { @MainActor in self?.isPlaying = playing }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 主动清理所有观察者,在 app 退出前调用
|
// MARK: - 清理
|
||||||
|
/// 主动清理所有观察者,在 view onDisappear 时调用
|
||||||
|
/// 必须在 PlayerBridge 被释放之前调用
|
||||||
func cleanup() {
|
func cleanup() {
|
||||||
|
isTearingDown = true
|
||||||
player.pause()
|
player.pause()
|
||||||
if let obs = timeObserver { player.removeTimeObserver(obs); timeObserver = nil }
|
|
||||||
|
// 1. 先停 Timer
|
||||||
|
updateTimer?.invalidate()
|
||||||
|
updateTimer = nil
|
||||||
|
|
||||||
|
// 2. 移除 KVO 观察者(阻止后续回调)
|
||||||
itemStatusObserver?.invalidate(); itemStatusObserver = nil
|
itemStatusObserver?.invalidate(); itemStatusObserver = nil
|
||||||
playbackStatusObserver?.invalidate(); playbackStatusObserver = nil
|
|
||||||
if let token = endObserverToken { NotificationCenter.default.removeObserver(token); endObserverToken = nil }
|
// 3. 移除通知观察者
|
||||||
// 注意:不调用 replaceCurrentItem(with: nil)
|
if let token = endObserverToken {
|
||||||
|
NotificationCenter.default.removeObserver(token)
|
||||||
|
endObserverToken = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 不调用 replaceCurrentItem(with: nil)
|
||||||
// 让 playerItem 随 player 自然释放,避免 CoreMedia 内部状态不一致
|
// 让 playerItem 随 player 自然释放,避免 CoreMedia 内部状态不一致
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
if let obs = timeObserver { player.removeTimeObserver(obs) }
|
// deinit 是 nonisolated,只做最小安全清理
|
||||||
itemStatusObserver?.invalidate()
|
// 大部分清理应在 cleanup() 中完成
|
||||||
playbackStatusObserver?.invalidate()
|
updateTimer?.invalidate()
|
||||||
if let token = endObserverToken { NotificationCenter.default.removeObserver(token) }
|
|
||||||
player.pause()
|
|
||||||
player.replaceCurrentItem(with: nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -412,17 +425,17 @@ final class PlayerBridge: ObservableObject {
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
class FullscreenPlayerView: NSView {
|
class FullscreenPlayerView: NSView {
|
||||||
|
private let playerLayer = AVPlayerLayer()
|
||||||
|
|
||||||
var player: AVPlayer? {
|
var player: AVPlayer? {
|
||||||
get { (layer as? AVPlayerLayer)?.player }
|
get { playerLayer.player }
|
||||||
set { (layer as? AVPlayerLayer)?.player = newValue }
|
set { playerLayer.player = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: NSRect) {
|
override init(frame: NSRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
wantsLayer = true
|
wantsLayer = true
|
||||||
let playerLayer = AVPlayerLayer()
|
|
||||||
playerLayer.videoGravity = .resizeAspect
|
playerLayer.videoGravity = .resizeAspect
|
||||||
layer = playerLayer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
@ -431,15 +444,16 @@ class FullscreenPlayerView: NSView {
|
|||||||
|
|
||||||
override func layout() {
|
override func layout() {
|
||||||
super.layout()
|
super.layout()
|
||||||
(layer as? AVPlayerLayer)?.frame = bounds
|
if playerLayer.superlayer !== layer {
|
||||||
|
layer?.addSublayer(playerLayer)
|
||||||
|
}
|
||||||
|
playerLayer.frame = bounds
|
||||||
}
|
}
|
||||||
|
|
||||||
override func mouseDown(with event: NSEvent) {
|
override func mouseDown(with event: NSEvent) {
|
||||||
if event.clickCount == 2 {
|
if event.clickCount == 2 {
|
||||||
// 双击退出全屏 — 通过通知
|
|
||||||
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
|
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
|
||||||
} else {
|
} else {
|
||||||
// 单击播放/暂停
|
|
||||||
if let p = player {
|
if let p = player {
|
||||||
if p.timeControlStatus == .playing { p.pause() } else { p.play() }
|
if p.timeControlStatus == .playing { p.pause() } else { p.play() }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user