import SwiftUI import AVFoundation import AVKit #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? private var fullscreenEventMonitor: Any? #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 { // 移除事件监控 if let monitor = fullscreenEventMonitor { NSEvent.removeMonitor(monitor) fullscreenEventMonitor = nil } // 先断开 player 引用再关闭窗口 if let playerView = fw.contentView as? AVPlayerView { 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 // 使用系统 AVPlayerView,Apple 内部处理所有 CoreMedia 生命周期竞态 let playerView = AVPlayerView(frame: screen.frame) playerView.player = player playerView.controlsStyle = .none playerView.videoGravity = .resizeAspect win.contentView = playerView win.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) fullscreenWindow = win isFullscreen = true // 添加本地事件监控:双击退出全屏、单击暂停/播放、Escape退出 fullscreenEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .keyDown]) { [weak self] event in guard let self = self, let fw = self.fullscreenWindow else { return event } // 只处理全屏窗口内的事件 if event.window !== fw { return event } if event.type == .leftMouseDown { if event.clickCount >= 2 { self.toggleFullscreen() return nil // 消费事件 } else { if self.player.timeControlStatus == .playing { self.player.pause() } else { self.player.play() } return nil } } else if event.type == .keyDown { if event.keyCode == 53 { // Escape self.toggleFullscreen() return nil } } return event } #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() #if os(macOS) // 移除全屏事件监控 if let monitor = fullscreenEventMonitor { NSEvent.removeMonitor(monitor) fullscreenEventMonitor = nil } // 关闭全屏窗口 if let fw = fullscreenWindow { if let pv = fw.contentView as? AVPlayerView { pv.player = nil } fw.close() fullscreenWindow = nil } #endif // 1. 先停 Timer updateTimer?.invalidate() updateTimer = nil // 2. 移除 KVO 观察者(阻止后续回调) itemStatusObserver?.invalidate(); itemStatusObserver = nil // 3. 移除通知观察者 if let token = endObserverToken { NotificationCenter.default.removeObserver(token) endObserverToken = nil } } deinit { // deinit 是 nonisolated,只做最小安全清理 // 大部分清理应在 cleanup() 中完成 updateTimer?.invalidate() } }