Root cause: when FullscreenPlayerView deallocates during window close, AVPlayerLayer.player reference is released via autorelease pool drain on main thread. CoreMedia background threads simultaneously access sFigNotificationCenterWeakListenerLinks dictionary (weak listener cleanup), causing use-after-free race condition. Fix: - toggleFullscreen(): set playerView.player = nil BEFORE fw.close() - FullscreenPlayerView.viewWillMove(toWindow: nil): safety net to nil out player when view is removed from window by any means This ensures the AVPlayer reference is released synchronously on the main thread, before any autorelease pool drain can race with CoreMedia internal cleanup threads.
479 lines
16 KiB
Swift
479 lines
16 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
#if os(macOS)
|
||
import AppKit
|
||
#endif
|
||
|
||
/// 播放队列项
|
||
struct MediaItem: Identifiable, Equatable {
|
||
let id: String
|
||
var url: URL
|
||
var name: String
|
||
var mediaType: String
|
||
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
|
||
}
|
||
|
||
/// 循环模式
|
||
enum RepeatMode: String, CaseIterable {
|
||
case none = "none"
|
||
case single = "single"
|
||
case all = "all"
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .none: return L.repeatNone
|
||
case .single: return L.repeatSingle
|
||
case .all: return L.repeatAll
|
||
}
|
||
}
|
||
|
||
var icon: String {
|
||
switch self {
|
||
case .none: return "➡️"
|
||
case .single: return "🔂"
|
||
case .all: return "🔁"
|
||
}
|
||
}
|
||
|
||
var next: RepeatMode {
|
||
switch self {
|
||
case .none: return .single
|
||
case .single: return .all
|
||
case .all: return .none
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class PlayerBridge: ObservableObject {
|
||
|
||
// MARK: - Published状态
|
||
@Published var showURLDialog = false
|
||
@Published var showTrackDialog = false
|
||
@Published var toastMessage: String?
|
||
@Published var availableTracks: [String] = []
|
||
@Published var currentTrackIndex: Int = 0
|
||
@Published var isFullscreen = false
|
||
@Published var showToolbar = false
|
||
@Published var isPlaying = false
|
||
@Published var currentTimeText = "00:00"
|
||
@Published var totalTimeText = "00:00"
|
||
@Published var currentTrackLabel = "🎵 Track 1"
|
||
@Published var volume: Float = 1.0
|
||
@Published var progressRatio: Double = 0
|
||
@Published var cachedDuration: Double = 0
|
||
@Published var queue: [MediaItem] = []
|
||
@Published var currentIndex: Int = -1
|
||
@Published var repeatMode: RepeatMode = .all
|
||
|
||
// MARK: - 内部状态
|
||
let player = AVPlayer()
|
||
// 用纯 Swift Timer 代替 addPeriodicTimeObserver
|
||
// addPeriodicTimeObserver 内部的 FigNotificationCenter weak listener 机制
|
||
// 与 autorelease pool drain 存在竞态,导致双重释放野指针崩溃
|
||
private var updateTimer: Timer?
|
||
private var itemStatusObserver: NSKeyValueObservation?
|
||
private var endObserverToken: NSObjectProtocol?
|
||
private var preferredTrackIndex: Int = 0
|
||
private var isTearingDown = false
|
||
|
||
#if os(macOS)
|
||
private var fullscreenWindow: NSWindow?
|
||
private var playlistWindow: NSWindow?
|
||
#endif
|
||
|
||
// MARK: - 初始化
|
||
func setup() {
|
||
player.volume = volume
|
||
|
||
setupTimeObserver()
|
||
setupEndObserver()
|
||
}
|
||
|
||
// MARK: - 播放控制
|
||
func togglePlayPause() {
|
||
if player.timeControlStatus == .playing {
|
||
player.pause()
|
||
isPlaying = false
|
||
} else {
|
||
player.play()
|
||
isPlaying = true
|
||
}
|
||
}
|
||
|
||
func playPrev() {
|
||
guard !queue.isEmpty else { return }
|
||
let idx = (currentIndex - 1 + queue.count) % queue.count
|
||
playIndex(idx)
|
||
}
|
||
|
||
func playNext() {
|
||
guard !queue.isEmpty else { return }
|
||
switch repeatMode {
|
||
case .none:
|
||
if currentIndex < queue.count - 1 { playIndex(currentIndex + 1) }
|
||
case .single:
|
||
player.seek(to: .zero); player.play()
|
||
case .all:
|
||
playIndex((currentIndex + 1) % queue.count)
|
||
}
|
||
}
|
||
|
||
func playIndex(_ index: Int) {
|
||
guard index >= 0 && index < queue.count else { return }
|
||
currentIndex = index
|
||
let item = queue[index]
|
||
|
||
// 先清除旧的 KVO,避免野指针
|
||
itemStatusObserver?.invalidate()
|
||
itemStatusObserver = nil
|
||
|
||
let playerItem = AVPlayerItem(url: item.url)
|
||
player.replaceCurrentItem(with: playerItem)
|
||
player.play()
|
||
isPlaying = true
|
||
|
||
cachedDuration = 0
|
||
progressRatio = 0
|
||
currentTimeText = "00:00"
|
||
totalTimeText = "00:00"
|
||
|
||
// KVO 可能从非主线程回调,用 DispatchQueue.main.async
|
||
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||
if pi.status == .readyToPlay {
|
||
DispatchQueue.main.async { self?.loadDuration(pi) }
|
||
}
|
||
}
|
||
|
||
loadTrackInfo(playerItem)
|
||
showToast(L.nowPlaying(item.name))
|
||
}
|
||
|
||
// MARK: - 播放结束
|
||
private func setupEndObserver() {
|
||
// queue: .main → 回调已在主线程,直接调用,不创建 Task
|
||
endObserverToken = NotificationCenter.default.addObserver(
|
||
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
|
||
) { [weak self] _ in
|
||
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 }
|
||
case .single:
|
||
player.seek(to: .zero); player.play()
|
||
case .all:
|
||
playNext()
|
||
}
|
||
}
|
||
|
||
// MARK: - 时间更新(纯 Timer,不经过 CoreMedia)
|
||
private func setupTimeObserver() {
|
||
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() {
|
||
// 只用 player.currentTime() 和 cachedDuration,
|
||
// 不访问 item.duration(会触发 CoreMedia FigNotificationCenter 竞态)
|
||
let current = player.currentTime().seconds
|
||
let total = cachedDuration
|
||
|
||
// 更新播放状态(替代 KVO,避免后台线程竞态)
|
||
isPlaying = (player.rate > 0 && player.error == nil)
|
||
|
||
currentTimeText = formatTime(current)
|
||
if total.isFinite && total > 0 {
|
||
totalTimeText = formatTime(total)
|
||
progressRatio = max(0, min(1, current / total))
|
||
}
|
||
}
|
||
|
||
private func loadDuration(_ item: AVPlayerItem) {
|
||
Task {
|
||
if let dur = try? await item.asset.load(.duration) {
|
||
let secs = dur.seconds
|
||
if secs.isFinite && secs > 0 {
|
||
cachedDuration = secs
|
||
totalTimeText = formatTime(secs)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func formatTime(_ seconds: Double) -> String {
|
||
guard seconds.isFinite && seconds >= 0 else { return "00:00" }
|
||
let h = Int(seconds) / 3600
|
||
let m = (Int(seconds) % 3600) / 60
|
||
let s = Int(seconds) % 60
|
||
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
|
||
return String(format: "%02d:%02d", m, s)
|
||
}
|
||
|
||
// MARK: - 音量
|
||
func adjustVolume(by delta: Float) {
|
||
volume = max(0, min(1, volume + delta))
|
||
player.volume = volume
|
||
}
|
||
|
||
func setVolume(_ val: Float) {
|
||
volume = max(0, min(1, val))
|
||
player.volume = volume
|
||
}
|
||
|
||
// MARK: - 音轨
|
||
private func loadTrackInfo(_ item: AVPlayerItem) {
|
||
Task {
|
||
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible) else {
|
||
availableTracks = ["Track 1"]
|
||
currentTrackIndex = 0
|
||
updateTrackLabel()
|
||
return
|
||
}
|
||
let options = group.options
|
||
availableTracks = options.enumerated().map { idx, _ in "Track \(idx + 1)" }
|
||
var targetIndex = preferredTrackIndex
|
||
if targetIndex >= options.count { targetIndex = 0 }
|
||
item.select(options[targetIndex], in: group)
|
||
currentTrackIndex = targetIndex
|
||
updateTrackLabel()
|
||
}
|
||
}
|
||
|
||
func selectTrack(index: Int) {
|
||
guard let item = player.currentItem else { return }
|
||
Task {
|
||
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible),
|
||
index < group.options.count else { return }
|
||
item.select(group.options[index], in: group)
|
||
currentTrackIndex = index
|
||
preferredTrackIndex = index
|
||
updateTrackLabel()
|
||
}
|
||
}
|
||
|
||
private func updateTrackLabel() {
|
||
currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||
}
|
||
|
||
// MARK: - 循环模式
|
||
func cycleRepeatMode() {
|
||
repeatMode = repeatMode.next
|
||
showToast(L.repeatModeLabel(repeatMode.displayName))
|
||
}
|
||
|
||
// MARK: - 全屏(视频内容全屏,非窗口全屏)
|
||
func toggleFullscreen() {
|
||
#if os(macOS)
|
||
if let fw = fullscreenWindow {
|
||
// 先断开 AVPlayerLayer → player 引用,避免 autorelease pool drain 时
|
||
// 与 CoreMedia 后台线程竞争 sFigNotificationCenterWeakListenerLinks
|
||
if let playerView = fw.contentView as? FullscreenPlayerView {
|
||
playerView.player = nil
|
||
}
|
||
fw.close()
|
||
fullscreenWindow = nil
|
||
isFullscreen = false
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
return
|
||
}
|
||
|
||
guard let screen = NSScreen.main else { return }
|
||
let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||
win.level = .screenSaver
|
||
win.backgroundColor = .black
|
||
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||
win.hasShadow = false
|
||
|
||
let playerView = FullscreenPlayerView(frame: screen.frame)
|
||
playerView.player = player
|
||
win.contentView = playerView
|
||
win.makeKeyAndOrderFront(nil)
|
||
win.makeFirstResponder(playerView)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
fullscreenWindow = win
|
||
isFullscreen = true
|
||
#endif
|
||
}
|
||
|
||
// MARK: - 文件操作
|
||
func openFileDialog() {
|
||
#if os(macOS)
|
||
let panel = NSOpenPanel()
|
||
panel.canChooseFiles = true
|
||
panel.canChooseDirectories = false
|
||
panel.allowsMultipleSelection = true
|
||
panel.allowedContentTypes = [
|
||
.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .avi, .mp3, .wav, .mpeg4Audio
|
||
]
|
||
if panel.runModal() == .OK {
|
||
for url in panel.urls {
|
||
addItem(url: url, name: url.lastPathComponent, type: "file")
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
func addURL(_ urlString: String) {
|
||
guard let url = URL(string: urlString) else { showToast(L.invalidURL); return }
|
||
let name = url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent
|
||
let type = urlString.contains(".m3u8") ? "stream" : "url"
|
||
addItem(url: url, name: name, type: type)
|
||
}
|
||
|
||
private func addItem(url: URL, name: String, type: String) {
|
||
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type))
|
||
if queue.count == 1 { playIndex(0) }
|
||
}
|
||
|
||
func removeItem(at index: Int) {
|
||
guard index >= 0 && index < queue.count else { return }
|
||
let wasPlaying = (index == currentIndex)
|
||
queue.remove(at: index)
|
||
if wasPlaying {
|
||
if queue.isEmpty {
|
||
currentIndex = -1
|
||
player.replaceCurrentItem(with: nil)
|
||
currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0
|
||
} else {
|
||
currentIndex = min(index, queue.count - 1)
|
||
playIndex(currentIndex)
|
||
}
|
||
} else if index < currentIndex {
|
||
currentIndex -= 1
|
||
}
|
||
}
|
||
|
||
// MARK: - 播放列表窗口
|
||
@Published var showPlaylistSheet = false
|
||
var lastInteraction = Date()
|
||
var isInteracting = false
|
||
|
||
func recordInteraction() { lastInteraction = Date() }
|
||
|
||
func togglePlaylistWindow() {
|
||
#if os(macOS)
|
||
if let win = playlistWindow, win.isVisible {
|
||
win.close(); playlistWindow = nil; return
|
||
}
|
||
let win = NSWindow(
|
||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false
|
||
)
|
||
win.title = "\(L.playlist) (\(queue.count))"
|
||
win.isReleasedWhenClosed = false
|
||
win.center()
|
||
win.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self))
|
||
win.makeKeyAndOrderFront(nil)
|
||
playlistWindow = win
|
||
#else
|
||
showPlaylistSheet.toggle()
|
||
#endif
|
||
}
|
||
|
||
// MARK: - Toast
|
||
private func showToast(_ message: String) {
|
||
toastMessage = message
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||
if self?.toastMessage == message { self?.toastMessage = nil }
|
||
}
|
||
}
|
||
|
||
// MARK: - 播放状态(已改为在 updateTimeDisplay 中检查 player.rate)
|
||
|
||
// MARK: - 清理
|
||
/// 主动清理所有观察者,在 view onDisappear 时调用
|
||
/// 必须在 PlayerBridge 被释放之前调用
|
||
func cleanup() {
|
||
isTearingDown = true
|
||
player.pause()
|
||
|
||
// 1. 先停 Timer
|
||
updateTimer?.invalidate()
|
||
updateTimer = nil
|
||
|
||
// 2. 移除 KVO 观察者(阻止后续回调)
|
||
itemStatusObserver?.invalidate(); itemStatusObserver = nil
|
||
|
||
// 3. 移除通知观察者
|
||
if let token = endObserverToken {
|
||
NotificationCenter.default.removeObserver(token)
|
||
endObserverToken = nil
|
||
}
|
||
|
||
// 4. 不调用 replaceCurrentItem(with: nil)
|
||
// 让 playerItem 随 player 自然释放,避免 CoreMedia 内部状态不一致
|
||
}
|
||
|
||
deinit {
|
||
// deinit 是 nonisolated,只做最小安全清理
|
||
// 大部分清理应在 cleanup() 中完成
|
||
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
|