diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 77605bf..0e3abc2 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -192,6 +192,27 @@ struct PlayerContentView: View { .onTapGesture { bridge.togglePlayPause() } + .onDrop(of: [.fileURL, .text], isTargeted: nil) { providers in + for provider in providers { + // 尝试作为文件 URL + provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { data, _ in + guard let data = data as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return } + DispatchQueue.main.async { + bridge.addItem(url: url, name: url.lastPathComponent, type: "file") + } + } + // 尝试作为文本 URL (http/https) + provider.loadItem(forTypeIdentifier: "public.text", options: nil) { data, _ in + guard let data = data as? Data, let text = String(data: data, encoding: .utf8), + let url = URL(string: text), url.scheme == "http" || url.scheme == "https" else { return } + DispatchQueue.main.async { + let name = url.lastPathComponent.isEmpty ? (url.host ?? text) : url.lastPathComponent + bridge.addItem(url: url, name: name, type: "url") + } + } + } + return true + } // 左上角应用图标 — 点击切换控制栏 MiniPlayerIcon() @@ -335,6 +356,13 @@ struct ControlToolbar: View { // 播放列表 TB(icon: "list.bullet") { bridge.togglePlaylistWindow(); onTouch() } + + Divider().frame(height: 20).padding(.horizontal, 4) + + // 录制 + TB(icon: bridge.screenRecorder.isRecording ? "stop.circle.fill" : "record.circle") { + bridge.toggleRecording(); onTouch() + } } } .padding(.horizontal, 12) diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 9232a7c..da021e0 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -84,6 +84,7 @@ final class PlayerBridge: ObservableObject { private var fullscreenWindow: NSWindow? private var playlistWindow: NSWindow? private var fullscreenEventMonitor: Any? + let screenRecorder = ScreenRecorder() #endif // MARK: - 初始化 @@ -92,6 +93,26 @@ final class PlayerBridge: ObservableObject { setupTimeObserver() setupEndObserver() + + #if os(macOS) + screenRecorder.onRecordingStopped = { [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 { + screenRecorder.startRecording() + } + #endif } // MARK: - 播放控制 @@ -358,7 +379,7 @@ final class PlayerBridge: ObservableObject { addItem(url: url, name: name, type: type) } - private func addItem(url: URL, name: String, type: String) { + 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) } } diff --git a/Sources/ScreenRecorder.swift b/Sources/ScreenRecorder.swift new file mode 100644 index 0000000..b2414d9 --- /dev/null +++ b/Sources/ScreenRecorder.swift @@ -0,0 +1,140 @@ +import AVFoundation +#if os(macOS) +import AppKit + +/// 屏幕录制器:录制屏幕 + 麦克风音频,保存到 ~/Movies/MiniPlayer/ +@MainActor +final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate { + + private var session: AVCaptureSession? + private var fileOutput: AVCaptureMovieFileOutput? + private var isConfigured = false + + @Published var isRecording = false + var onRecordingStopped: ((URL) -> Void)? + var onError: ((String) -> Void)? + + /// 保存目录 + private var saveDirectory: URL { + let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first! + let dir = movies.appendingPathComponent("MiniPlayer", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// 开始录制 + func startRecording() { + guard !isRecording else { return } + + // 首次配置 session(避免每次重建) + if !isConfigured { + guard configureSession() else { return } + isConfigured = true + } + + guard let fileOutput = fileOutput else { + onError?("Recording not ready") + return + } + + // 生成文件名:录制_2026-06-24_15-30-45.mp4 + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + let filename = "Recording_\(formatter.string(from: Date())).mp4" + let outputURL = saveDirectory.appendingPathComponent(filename) + + // 启动 session 并开始录制 + if let session = session, !session.isRunning { + session.startRunning() + } + + fileOutput.startRecording(to: outputURL, recordingDelegate: self) + } + + /// 停止录制 + func stopRecording() { + guard isRecording, let fileOutput = fileOutput else { return } + fileOutput.stopRecording() + } + + private func configureSession() -> Bool { + let session = AVCaptureSession() + session.sessionPreset = .hd1920x1080 + + // 屏幕输入(主显示器) + guard let displayID = NSScreen.main?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else { + onError?("No display found") + return false + } + + let screenInput = AVCaptureScreenInput(displayID: displayID) + guard let screenInput = screenInput else { + onError?("Cannot create screen input") + return false + } + screenInput.capturesMouseClicks = false + screenInput.capturesCursor = true + + if session.canAddInput(screenInput) { + session.addInput(screenInput) + } else { + onError?("Cannot add screen input") + return false + } + + // 麦克风输入 + if let audioDevice = AVCaptureDevice.default(for: .audio), + let audioInput = try? AVCaptureDeviceInput(device: audioDevice) { + if session.canAddInput(audioInput) { + session.addInput(audioInput) + } + } + // 没有麦克风也能录制(只有画面) + + // 文件输出 + let output = AVCaptureMovieFileOutput() + if session.canAddOutput(output) { + session.addOutput(output) + } else { + onError?("Cannot add file output") + return false + } + + self.session = session + self.fileOutput = output + + // 请求屏幕录制权限(macOS 10.15+) + // CGPreflightScreenCaptureAccess 在首次调用时触发权限弹窗 + #if canImport(ScreenCaptureKit) + CGPreflightScreenCaptureAccess() + #endif + + return true + } + + // MARK: - AVCaptureFileOutputRecordingDelegate + nonisolated func fileOutputRecordingDidStart(_ output: AVCaptureFileOutput) { + Task { @MainActor in + self.isRecording = true + } + } + + nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { + Task { @MainActor in + self.isRecording = false + if let error = error { + self.onError?("Recording failed: \(error.localizedDescription)") + } else { + self.onRecordingStopped?(outputFileURL) + } + // 停止 session 释放资源 + self.session?.stopRunning() + } + } + + deinit { + session?.stopRunning() + } +} + +#endif