From 87c66c3c701abe1b304270018c67475f334f8e26 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Wed, 24 Jun 2026 19:56:07 +0800 Subject: [PATCH] Recording: red icon, live duration display, save dialog with editable filename --- Sources/MiniPlayerApp.swift | 17 ++++++++- Sources/PlayerBridge.swift | 2 +- Sources/ScreenRecorder.swift | 71 ++++++++++++++++++++++++++++++++---- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 53ca852..8b42cb9 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -325,8 +325,21 @@ struct ControlToolbar: View { // 按钮行 HStack(spacing: 10) { // 录制(放最前面,更显眼) - TB(icon: bridge.screenRecorder.isRecording ? "stop.circle.fill" : "record.circle") { - bridge.toggleRecording(); onTouch() + if bridge.screenRecorder.isRecording { + HStack(spacing: 4) { + Image(systemName: "stop.circle.fill") + .foregroundColor(.red) + .font(.system(size: 18)) + Text(bridge.screenRecorder.durationText) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.red) + } + .contentShape(Rectangle()) + .onTapGesture { bridge.toggleRecording(); onTouch() } + } else { + TB(icon: "record.circle") { + bridge.toggleRecording(); onTouch() + } } Divider().frame(height: 20).padding(.horizontal, 4) diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 11726c3..d512247 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -100,7 +100,7 @@ final class PlayerBridge: ObservableObject { setupEndObserver() #if os(macOS) - screenRecorder.onRecordingStopped = { [weak self] url in + screenRecorder.onRecordingSaved = { [weak self] url in self?.showToast("Saved: \(url.lastPathComponent)") } screenRecorder.onError = { [weak self] msg in diff --git a/Sources/ScreenRecorder.swift b/Sources/ScreenRecorder.swift index 9efd15f..88fdb44 100644 --- a/Sources/ScreenRecorder.swift +++ b/Sources/ScreenRecorder.swift @@ -9,9 +9,13 @@ final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate { private var session: AVCaptureSession? private var fileOutput: AVCaptureMovieFileOutput? private var isConfigured = false + private var timer: Timer? + private var startDate: Date? @Published var isRecording = false - var onRecordingStopped: ((URL) -> Void)? + @Published var durationText = "00:00" + + var onRecordingSaved: ((URL) -> Void)? var onError: ((String) -> Void)? /// 保存目录 @@ -51,12 +55,66 @@ final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate { fileOutput.startRecording(to: outputURL, recordingDelegate: self) } - /// 停止录制 + /// 停止录制 → 弹出保存对话框 func stopRecording() { guard isRecording, let fileOutput = fileOutput else { return } fileOutput.stopRecording() } + private func startTimer() { + startDate = Date() + durationText = "00:00" + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self = self, let start = self.startDate else { return } + let elapsed = Int(Date().timeIntervalSince(start)) + let m = elapsed / 60 + let s = elapsed % 60 + let h = m / 60 + if h > 0 { + self.durationText = String(format: "%d:%02d:%02d", h, m % 60, s) + } else { + self.durationText = String(format: "%02d:%02d", m, s) + } + } + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + startDate = nil + durationText = "00:00" + } + + /// 弹出保存对话框 + private func showSaveDialog(tempURL: URL) { + let panel = NSSavePanel() + panel.nameFieldStringValue = tempURL.lastPathComponent + panel.allowedContentTypes = [.mpeg4Movie] + panel.directoryURL = saveDirectory + panel.title = "Save Recording" + + panel.begin { [weak self] response in + guard let self = self else { return } + if response == .OK, let destURL = panel.url { + do { + // 如果目标已存在先删除 + if FileManager.default.fileExists(atPath: destURL.path) { + try FileManager.default.removeItem(at: destURL) + } + try FileManager.default.moveItem(at: tempURL, to: destURL) + self.onRecordingSaved?(destURL) + } catch { + self.onError?("Save failed: \(error.localizedDescription)") + } + } else { + // 用户取消 → 删除临时文件 + try? FileManager.default.removeItem(at: tempURL) + } + } + } + private func configureSession() -> Bool { let session = AVCaptureSession() session.sessionPreset = .hd1920x1080 @@ -89,7 +147,6 @@ final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate { session.addInput(audioInput) } } - // 没有麦克风也能录制(只有画面) // 文件输出 let output = AVCaptureMovieFileOutput() @@ -103,8 +160,7 @@ final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate { self.session = session self.fileOutput = output - // 请求屏幕录制权限(macOS 10.15+) - // CGRequestScreenCaptureAccess 在首次调用时触发权限弹窗 + // 请求屏幕录制权限 #if canImport(ScreenCaptureKit) if !CGPreflightScreenCaptureAccess() { CGRequestScreenCaptureAccess() @@ -118,18 +174,19 @@ final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate { nonisolated func fileOutputRecordingDidStart(_ output: AVCaptureFileOutput) { Task { @MainActor in self.isRecording = true + self.startTimer() } } nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { Task { @MainActor in self.isRecording = false + self.stopTimer() if let error = error { self.onError?("Recording failed: \(error.localizedDescription)") } else { - self.onRecordingStopped?(outputFileURL) + self.showSaveDialog(tempURL: outputFileURL) } - // 停止 session 释放资源 self.session?.stopRunning() } }