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 { 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 { private let playerLayer = 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 } override func layout() { super.layout() 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() } } } } override func keyDown(with event: NSEvent) { if event.keyCode == 53 { // Escape NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil) } } } #endif