import SwiftUI import AVFoundation import AVKit #if os(macOS) import AppKit #elseif os(iOS) import UIKit #endif /// 播放队列项 struct MediaItem: Identifiable, Equatable { let id: String var url: URL var name: String var mediaType: String var libraryID: String? // 关联媒体库条目ID(用于播放记忆) static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id } } /// 播放器加载状态 enum PlayerStatus: Equatable { case idle // 无媒体 case loading // 正在连接/加载 case buffering // 播放中缓冲 case playing // 正常播放 case error(String) // 播放错误 static func == (lhs: PlayerStatus, rhs: PlayerStatus) -> Bool { switch (lhs, rhs) { case (.idle, .idle), (.loading, .loading), (.buffering, .buffering), (.playing, .playing): return true case (.error(let a), .error(let b)): return a == b default: return false } } } /// 循环模式 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 @Published var showLibrarySheet = false @Published var showFileImporter = false @Published var showNetworkBrowser = false #if os(macOS) var qrScannerController: QRScannerWindowController? #endif @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 @Published var playerStatus: PlayerStatus = .idle @Published var loadingElapsed: Int = 0 // 加载已用秒数 // MARK: - 内部状态 let player = AVPlayer() let library = MediaLibrary() // 用纯 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 private var loadingTimer: Timer? #if os(macOS) private var fullscreenWindow: NSWindow? private var playlistWindow: NSWindow? private var libraryWindow: NSWindow? private var fullscreenEventMonitor: Any? let screenRecorder = PlayerRecorder() #endif #if os(iOS) let hlsRecorder = HLSRecorder() let pipController = PiPController() @Published var pendingExportURL: URL? #endif // 诊断日志 private var diagLogURL: URL? private var diagLogHandle: FileHandle? private func startDiagnosticLog() { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] diagLogURL = docs.appendingPathComponent("miniplayer_diag.log") // 清空旧日志 try? "".write(to: diagLogURL!, atomically: true, encoding: .utf8) diagLogHandle = try? FileHandle(forWritingTo: diagLogURL!) diagLogHandle?.seekToEndOfFile() diagLog("=== MiniPlayer diagnostic log started ===") } func diagLog(_ msg: String) { let line = "\(Date()): \(msg)\n" print("[MiniPlayer] \(msg)") if let data = line.data(using: .utf8) { try? diagLogHandle?.write(contentsOf: data) } } // MARK: - 初始化 func setup() { #if os(macOS) // 设置为正常 GUI 应用,显示在 Dock 中 NSApp.setActivationPolicy(.regular) #elseif os(iOS) // iOS: 配置音频会话(支持后台播放 + PiP) ensureAudioSession() // 开启诊断日志文件 startDiagnosticLog() #endif player.volume = volume // 启用 AirPlay(iOS 和 macOS 通用) player.allowsExternalPlayback = true 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 #if os(iOS) hlsRecorder.onRecordingSaved = { [weak self] url in self?.showToast("Saved: \(url.lastPathComponent)") self?.pendingExportURL = url } hlsRecorder.onError = { [weak self] msg in self?.showToast(msg) } #endif } #if os(iOS) /// 配置 AVAudioSession,支持后台音频和 PiP private func ensureAudioSession() { let session = AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .default) try session.setActive(true) } catch { diagLog("AudioSession error: \(error.localizedDescription)") } } #endif // MARK: - 录制 func toggleRecording() { #if os(macOS) if screenRecorder.isRecording { screenRecorder.stopRecording() } else { guard !queue.isEmpty, currentIndex >= 0, currentIndex < queue.count else { showToast("No media playing") return } let item = queue[currentIndex] screenRecorder.startRecording(url: item.url) } #elseif os(iOS) if hlsRecorder.isRecording { hlsRecorder.stopRecording() } else { guard !queue.isEmpty, currentIndex >= 0, currentIndex < queue.count else { showToast("No media playing") return } let item = queue[currentIndex] hlsRecorder.startRecording(url: item.url) } #endif } // MARK: - 播放控制 func togglePlayPause() { if player.timeControlStatus == .playing { saveCurrentPosition() 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) } } // 播放器报错的诊断消息(存在这里,和错误一起显示) private var lastDiagMsg = "" func playIndex(_ index: Int) { guard index >= 0 && index < queue.count else { return } // 保存当前播放位置(播放记忆) saveCurrentPosition() currentIndex = index let item = queue[index] diagLog("playIndex(\(index)): \(item.url.lastPathComponent)") // 本地文件诊断:检查文件是否存在和可读 if item.url.isFileURL { let path = item.url.path let exists = FileManager.default.fileExists(atPath: path) let readable = FileManager.default.isReadableFile(atPath: path) let fileSize = (try? FileManager.default.attributesOfItem(atPath: path)[.size] as? Int) ?? 0 // 读前4字节验证文件不是空的/损坏的 var magic: UInt32 = 0 if let fh = try? FileHandle(forReadingFrom: item.url) { if let d = try? fh.read(upToCount: 4), d.count >= 4 { magic = d.withUnsafeBytes { $0.load(as: UInt32.self) } } try? fh.close() } diagLog("Local file check: exists=\(exists) readable=\(readable) size=\(fileSize) magic=\(String(format:"%08X", magic))") lastDiagMsg = "file: exists=\(exists) readable=\(readable)\nsize=\(fileSize) magic=\(String(format:"%08X", magic))" } // 先清除旧的 KVO,避免野指针 itemStatusObserver?.invalidate() itemStatusObserver = nil // 用 AVURLAsset 支持自定义 HTTP headers(某些 HLS 流需要 Referer/UA) var assetOptions: [String: Any] = [:] if item.url.scheme == "http" || item.url.scheme == "https" { let headers: [String: String] = [ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" ] assetOptions["AVURLAssetHTTPHeaderFieldsKey"] = headers } let asset = AVURLAsset(url: item.url, options: assetOptions.isEmpty ? nil : assetOptions) let playerItem = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: playerItem) #if os(iOS) ensureAudioSession() #endif player.play() isPlaying = true diagLog("play() called, rate=\(player.rate) timeControlStatus=\(player.timeControlStatus.rawValue)") cachedDuration = 0 progressRatio = 0 currentTimeText = "00:00" totalTimeText = "00:00" // 开始加载状态追踪 playerStatus = .loading loadingElapsed = 0 loadingTimer?.invalidate() loadingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in DispatchQueue.main.async { guard let self = self else { return } if case .loading = self.playerStatus { self.loadingElapsed += 1 } else { self.loadingTimer?.invalidate() self.loadingTimer = nil } } } // 查找媒体库条目,获取保存的位置 let savedPosition: Double = { if let libID = item.libraryID { return library.getPosition(for: libID) } return library.findItem(byURL: item.url.absoluteString)?.lastPosition ?? 0 }() // KVO 可能从非主线程回调,用 DispatchQueue.main.async itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in self?.diagLog("KVO status: \(pi.status.rawValue)") DispatchQueue.main.async { switch pi.status { case .readyToPlay: self?.playerStatus = .playing self?.loadingTimer?.invalidate() self?.loadingTimer = nil self?.loadDuration(pi) // 恢复播放位置(仅对点播内容,直播流跳过) let dur = self?.cachedDuration ?? 0 if savedPosition > 1.0 && dur > 0 { let pos = min(savedPosition, dur - 5) if pos > 0 { self?.player.seek(to: CMTime(seconds: pos, preferredTimescale: 600)) print("[MiniPlayer] Resumed at \(pos)s") } } case .failed: let err = pi.error let errMsg = err?.localizedDescription ?? "Unknown error" let errCode = (err as NSError?)?.code ?? 0 let errDomain = (err as NSError?)?.domain ?? "none" let fullMsg = "\(errMsg)\n[code:\(errCode) domain:\(errDomain)]\n\(self?.lastDiagMsg ?? "")\n\(item.url.lastPathComponent)" self?.diagLog("AVPlayerItem FAILED: \(errMsg) code=\(errCode) domain=\(errDomain)") self?.playerStatus = .error(fullMsg) self?.loadingTimer?.invalidate() self?.loadingTimer = nil self?.isPlaying = false default: break } } } loadTrackInfo(playerItem) showToast(L.nowPlaying(item.name)) } // MARK: - 播放结束 private func setupEndObserver() { endObserverToken = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main ) { [weak self] notification in guard let self = self else { return } // 只处理当前 item 的结束通知,忽略旧 item 被替换时发出的残留通知 guard let endedItem = notification.object as? AVPlayerItem, endedItem === self.player.currentItem 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) // 检测缓冲状态(仅在已 readyToPlay 后检测,避免覆盖 loading) if case .playing = playerStatus { if player.timeControlStatus == .waitingToPlayAtSpecifiedRate { playerStatus = .buffering } } else if case .buffering = playerStatus { if player.timeControlStatus == .playing { playerStatus = .playing } } 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() } } func cycleTrack() { guard availableTracks.count > 1 else { return } let nextIndex = (currentTrackIndex + 1) % availableTracks.count selectTrack(index: nextIndex) } private func updateTrackLabel() { currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))" } // MARK: - 外部字幕加载(骨架,后续 ASS 解析在此接入) /// 存储当前加载的外部字幕文件 URL @Published var externalSubtitleURL: URL? // SMB 流式加载器引用(保持存活) private var streamServer: LocalStreamServer? func loadExternalSubtitle(url: URL) { externalSubtitleURL = url diagLog("External subtitle loaded: \(url.lastPathComponent)") showToast("📝 \(url.lastPathComponent)") } /// SMB 文件流式播放 — 启动本地 HTTP 服务器,AVPlayer 从 localhost 播放 func playSMBSource(url smbURL: URL, name: String) { // 停止旧服务器 streamServer?.stop() let server = LocalStreamServer(smbURL: smbURL) do { try server.start() } catch { diagLog("Stream server start failed: \(error)") showToast("Failed: \(error.localizedDescription)") return } self.streamServer = server guard let localURL = server.localURL else { showToast("Failed to get stream URL") return } diagLog("Streaming \(name) via \(localURL.absoluteString)") let asset = AVURLAsset(url: localURL) let playerItem = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: playerItem) #if os(iOS) ensureAudioSession() #endif player.play() isPlaying = true cachedDuration = 0; progressRatio = 0 currentTimeText = "00:00"; totalTimeText = "00:00" playerStatus = .loading; loadingElapsed = 0 loadingTimer?.invalidate() loadingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in DispatchQueue.main.async { guard let self = self else { return } if case .loading = self.playerStatus { self.loadingElapsed += 1 } else { self.loadingTimer?.invalidate(); self.loadingTimer = nil } } } itemStatusObserver?.invalidate() itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in DispatchQueue.main.async { switch pi.status { case .readyToPlay: self?.playerStatus = .playing self?.loadingTimer?.invalidate(); self?.loadingTimer = nil self?.loadDuration(pi) case .failed: let err = pi.error; let msg = err?.localizedDescription ?? "Unknown" self?.playerStatus = .error(msg) self?.loadingTimer?.invalidate(); self?.loadingTimer = nil self?.isPlaying = false default: break } } } showToast(L.nowPlaying(name)) } // MARK: - 网络文件浏览 func toggleNetworkBrowser() { showNetworkBrowser = true } // 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") } } #elseif os(iOS) showFileImporter = true #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) { let urlString = url.absoluteString print("[MiniPlayer] addItem: name=\(name), type=\(type), scheme=\(url.scheme ?? "nil")") // 检查队列是否已有相同URL if queue.contains(where: { $0.url.absoluteString == urlString }) { print("[MiniPlayer] addItem: already in queue, skipping") showToast("⚠️ \(name) already in queue") return } // 自动入库(库内已自动去重) let libItem = library.addItem(url: urlString, name: name, type: type) queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type, libraryID: libItem.id)) print("[MiniPlayer] addItem: added to queue, count=\(queue.count)") if queue.count == 1 { print("[MiniPlayer] addItem: first item, calling playIndex(0)") 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 playerStatus = .idle } 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: - 播放位置记忆 /// 保存当前播放位置到媒体库 func saveCurrentPosition() { guard currentIndex >= 0, currentIndex < queue.count else { return } let position = player.currentTime().seconds guard position.isFinite && position > 1.0 else { return } let item = queue[currentIndex] if let libID = item.libraryID { library.savePosition(libID, position: position) } else if let libItem = library.findItem(byURL: item.url.absoluteString) { library.savePosition(libItem.id, position: position) } } /// 强制保存位置(退出时,不等debounce) func saveCurrentPositionImmediate() { guard currentIndex >= 0, currentIndex < queue.count else { return } let position = player.currentTime().seconds guard position.isFinite && position > 1.0 else { return } let item = queue[currentIndex] if let libID = item.libraryID { library.savePositionImmediate(libID, position: position) } else if let libItem = library.findItem(byURL: item.url.absoluteString) { library.savePositionImmediate(libItem.id, position: position) } } // MARK: - 媒体库操作 /// 从媒体库播放条目(替换当前播放,保存位置) func playFromLibrary(_ item: LibraryItem) { guard let url = URL(string: item.url) else { showToast(L.invalidURL) return } // 添加到队列并播放 let mediaItem = MediaItem(id: UUID().uuidString, url: url, name: item.name, mediaType: item.type, libraryID: item.id) queue.append(mediaItem) playIndex(queue.count - 1) } /// 添加到队列但不播放(用于批量加入) func addItemNoPlay(url: URL, name: String, type: String, libraryID: String? = nil) { // 队列去重 if queue.contains(where: { $0.url.absoluteString == url.absoluteString }) { return } queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type, libraryID: libraryID)) // 如果队列为空则自动播放第一个 if queue.count == 1 { playIndex(0) } } /// 导入本地文件到媒体库 func importLocalToLibrary() { #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 { var count = 0 for url in panel.urls { library.addItem(url: url.absoluteString, name: url.lastPathComponent, type: "local") addItemNoPlay(url: url, name: url.lastPathComponent, type: "local", libraryID: library.findItem(byURL: url.absoluteString)?.id) count += 1 } showToastMsg("\(L.imported) \(count) \(L.localFilesAdded)") } #elseif os(iOS) showFileImporter = true #endif } /// 处理 iOS 文件选择器结果 func handleFileImport(_ urls: [URL]) { diagLog("handleFileImport: \(urls.count) URLs") let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] for url in urls { diagLog("processing: \(url.lastPathComponent)") let didStartAccessing = url.startAccessingSecurityScopedResource() diagLog("startAccessingSecurityScopedResource: \(didStartAccessing)") // 先把文件数据读到内存(安全域访问有效期内) let fileData: Data do { fileData = try Data(contentsOf: url) diagLog("read \(fileData.count) bytes from source") } catch { diagLog("read failed: \(error.localizedDescription)") if didStartAccessing { url.stopAccessingSecurityScopedResource() } continue } // 撤销安全域访问(文件数据已在内存中) if didStartAccessing { url.stopAccessingSecurityScopedResource() } // 写入 Documents,确保用唯一的文件名 let baseName = url.deletingPathExtension().lastPathComponent let ext = url.pathExtension var localURL = docsDir.appendingPathComponent(url.lastPathComponent) if FileManager.default.fileExists(atPath: localURL.path) { let ts = Int(Date().timeIntervalSince1970) localURL = docsDir.appendingPathComponent("\(baseName)_\(ts).\(ext)") } do { try fileData.write(to: localURL) diagLog("written \(fileData.count) bytes to: \(localURL.lastPathComponent)") addItem(url: localURL, name: url.lastPathComponent, type: "file") } catch { diagLog("write failed: \(error.localizedDescription)") showToast("Failed to import: \(url.lastPathComponent)") } } } // MARK: - 媒体库窗口 func toggleLibraryWindow() { #if os(macOS) if let win = libraryWindow, win.isVisible { win.close(); libraryWindow = nil; return } let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 700, height: 550), styleMask: [.titled, .closable, .resizable, .utilityWindow], backing: .buffered, defer: false ) panel.title = L.mediaLibrary panel.isReleasedWhenClosed = false panel.isMovableByWindowBackground = true panel.hidesOnDeactivate = false panel.becomesKeyOnlyIfNeeded = true panel.center() panel.contentView = NSHostingView(rootView: MediaLibraryView(library: library, bridge: self)) panel.makeKeyAndOrderFront(NSApp) libraryWindow = panel #elseif os(iOS) showLibrarySheet.toggle() #endif } // MARK: - 公开 Toast func showToastMsg(_ message: String) { showToast(message) } // 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: - 队列持久化(跨启动恢复) private static let queueKey = "MiniPlayer.savedQueue" private static let indexKey = "MiniPlayer.savedIndex" private static let positionKey = "MiniPlayer.savedPosition" private static let repeatModeKey = "MiniPlayer.savedRepeatMode" /// 保存当前队列和播放位置到 UserDefaults(app 进后台/退出时调用) func saveSessionState() { guard !queue.isEmpty, currentIndex >= 0 else { return } let items = queue.map { item -> [String: String] in [ "url": item.url.absoluteString, "name": item.name, "type": item.mediaType, "libraryID": item.libraryID ?? "" ] } let position = player.currentTime().seconds UserDefaults.standard.set(items, forKey: Self.queueKey) UserDefaults.standard.set(currentIndex, forKey: Self.indexKey) UserDefaults.standard.set(position.isFinite ? position : 0, forKey: Self.positionKey) UserDefaults.standard.set(repeatMode.rawValue, forKey: Self.repeatModeKey) UserDefaults.standard.synchronize() // 同时保存媒体库位置 library.saveSync() NSLog("[MiniPlayer] Session saved: \(queue.count) items, index=\(currentIndex), position=\(position)") } /// 从 UserDefaults 恢复队列和播放位置(app 启动时调用) func restoreSessionState() { guard let items = UserDefaults.standard.array(forKey: Self.queueKey) as? [[String: String]], !items.isEmpty else { NSLog("[MiniPlayer] No saved session to restore") return } let savedIndex = UserDefaults.standard.integer(forKey: Self.indexKey) let savedPosition = UserDefaults.standard.double(forKey: Self.positionKey) if let modeStr = UserDefaults.standard.string(forKey: Self.repeatModeKey), let mode = RepeatMode(rawValue: modeStr) { repeatMode = mode } // 重建队列 var restoredQueue: [MediaItem] = [] for dict in items { guard let urlStr = dict["url"], let url = URL(string: urlStr) else { continue } let name = dict["name"] ?? url.lastPathComponent let type = dict["type"] ?? "url" let libID = dict["libraryID"]?.isEmpty == false ? dict["libraryID"] : nil restoredQueue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type, libraryID: libID)) } guard !restoredQueue.isEmpty else { NSLog("[MiniPlayer] Restored queue is empty after URL parsing") return } queue = restoredQueue let idx = min(savedIndex, restoredQueue.count - 1) NSLog("[MiniPlayer] Restoring session: \(restoredQueue.count) items, index=\(idx), position=\(savedPosition)") // 播放并 seek 到上次位置 playIndex(idx) // seek 需要等 item readyToPlay,用延迟方式 if savedPosition > 1.0 { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in guard let self else { return } let dur = self.cachedDuration if dur > 0 && savedPosition < dur - 5 { self.player.seek(to: CMTime(seconds: savedPosition, preferredTimescale: 600)) self.showToast("Resumed at \(self.formatTime(savedPosition))") NSLog("[MiniPlayer] Resumed at \(savedPosition)s") } } } } // MARK: - 清理 /// 主动清理所有观察者,在 view onDisappear 时调用 /// 必须在 PlayerBridge 被释放之前调用 func cleanup() { isTearingDown = true // 保存队列和播放位置(跨启动恢复) saveSessionState() // 强制保存播放位置到媒体库(退出前记忆,不等debounce) saveCurrentPositionImmediate() 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 loadingTimer?.invalidate() loadingTimer = nil // 2. 移除 KVO 观察者 itemStatusObserver?.invalidate(); itemStatusObserver = nil // 3. 移除通知观察者 if let token = endObserverToken { NotificationCenter.default.removeObserver(token) endObserverToken = nil } } deinit { // deinit 是 nonisolated,只做最小安全清理 // 大部分清理应在 cleanup() 中完成 updateTimer?.invalidate() loadingTimer?.invalidate() } }