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 showQRScanner = false var qrScannerController: QRScannerWindowController? @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? let screenRecorder = PlayerRecorder() var videoOutput: AVPlayerItemVideoOutput? #endif // MARK: - 初始化 func setup() { #if os(macOS) // 设置为正常 GUI 应用,显示在 Dock 中 NSApp.setActivationPolicy(.regular) #endif player.volume = volume setupTimeObserver() setupEndObserver() #if os(macOS) screenRecorder.onRecordingSaved = { [weak self] url in self?.showToast("Saved: \(url.lastPathComponent)") } screenRecorder.onError = { [weak self] msg in self?.showToast(msg) } #endif } // MARK: - 录制 func toggleRecording() { #if os(macOS) if screenRecorder.isRecording { screenRecorder.stopRecording() } else { guard let output = videoOutput else { showToast("No video playing") return } guard !queue.isEmpty else { return } let startTime = player.currentTime() guard let playerItem = player.currentItem else { showToast("No player item") return } screenRecorder.startRecording(from: output, playerItem: playerItem, startTime: startTime) } #endif } // 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] print("[MiniPlayer] playIndex(\(index)): \(item.url.absoluteString)") // 先清除旧的 KVO,避免野指针 itemStatusObserver?.invalidate() itemStatusObserver = nil // 用 AVURLAsset 支持自定义 HTTP headers(某些 HLS 流需要 Referer/UA) let headers: [String: String] = [ "Referer": item.url.deletingLastPathComponent().absoluteString, "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" ] let asset = AVURLAsset(url: item.url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) let playerItem = AVPlayerItem(asset: asset) #if os(macOS) // 添加 video output 用于录制 let pixelBufferAttributes: [String: Any] = [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA ] let output = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferAttributes) playerItem.add(output) videoOutput = output #endif player.replaceCurrentItem(with: playerItem) player.play() isPlaying = true print("[MiniPlayer] player.play() called, rate=\(player.rate)") cachedDuration = 0 progressRatio = 0 currentTimeText = "00:00" totalTimeText = "00:00" // KVO 可能从非主线程回调,用 DispatchQueue.main.async itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in print("[MiniPlayer] KVO status changed: \(pi.status.rawValue), error=\(pi.error?.localizedDescription ?? "none")") 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: - 全屏(视频内容全屏,非窗口全屏) // 全屏窗口只创建一次,之后只用 orderFront/orderOut 切换显示 // 使用 NSHostingView + SwiftUI,与主窗口共享相同的图标和控制栏 func toggleFullscreen() { #if os(macOS) if isFullscreen { // 退出全屏:只隐藏窗口,不销毁 fullscreenWindow?.orderOut(nil) isFullscreen = false if let monitor = fullscreenEventMonitor { NSEvent.removeMonitor(monitor) fullscreenEventMonitor = nil } NSApp.activate(ignoringOtherApps: true) return } // 首次全屏:创建窗口和 SwiftUI 内容(仅此一次) if fullscreenWindow == nil { 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 win.isReleasedWhenClosed = false // 用 SwiftUI PlayerContentView 渲染全屏(含图标+控制栏) let hostingView = NSHostingView(rootView: PlayerContentView(bridge: self)) hostingView.frame = screen.frame win.contentView = hostingView fullscreenWindow = win } fullscreenWindow?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) isFullscreen = true // Escape 键退出全屏 if fullscreenEventMonitor == nil { fullscreenEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in guard let self = self, self.isFullscreen else { return event } guard event.window === self.fullscreenWindow else { return event } if event.type == .keyDown && event.keyCode == 53 { // Escape DispatchQueue.main.async { 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) { let urlString = urlString.trimmingCharacters(in: .whitespacesAndNewlines) print("[MiniPlayer] addURL called: \(urlString)") 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" print("[MiniPlayer] Adding item: name=\(name), type=\(type)") addItem(url: url, name: name, type: type) } 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() } // MARK: - 扫码窗口 func showQRScannerWindow() { #if os(macOS) if qrScannerController == nil { qrScannerController = QRScannerWindowController(bridge: self) } qrScannerController?.show() #endif } func togglePlaylistWindow() { #if os(macOS) if let win = playlistWindow, win.isVisible { win.close(); playlistWindow = nil; return } let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 400, height: 500), styleMask: [.titled, .closable, .resizable, .utilityWindow], backing: .buffered, defer: false ) panel.title = "\(L.playlist) (\(queue.count))" panel.isReleasedWhenClosed = false panel.isMovableByWindowBackground = true panel.hidesOnDeactivate = false panel.becomesKeyOnlyIfNeeded = true panel.center() panel.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self)) panel.makeKeyAndOrderFront(NSApp) playlistWindow = panel #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) // 移除全屏事件监控(不销毁窗口,避免 teardown 竞态) if let monitor = fullscreenEventMonitor { NSEvent.removeMonitor(monitor) fullscreenEventMonitor = nil } fullscreenWindow?.orderOut(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() } }