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:
yumoqing 2026-06-22 00:57:23 +08:00
parent f14d47b5c8
commit c336066198
3 changed files with 70 additions and 44 deletions

View 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>

Binary file not shown.

View File

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