Recording: red icon, live duration display, save dialog with editable filename
This commit is contained in:
parent
35de7263e9
commit
87c66c3c70
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user