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) {
|
HStack(spacing: 10) {
|
||||||
// 录制(放最前面,更显眼)
|
// 录制(放最前面,更显眼)
|
||||||
TB(icon: bridge.screenRecorder.isRecording ? "stop.circle.fill" : "record.circle") {
|
if bridge.screenRecorder.isRecording {
|
||||||
bridge.toggleRecording(); onTouch()
|
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)
|
Divider().frame(height: 20).padding(.horizontal, 4)
|
||||||
|
|||||||
@ -100,7 +100,7 @@ final class PlayerBridge: ObservableObject {
|
|||||||
setupEndObserver()
|
setupEndObserver()
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
screenRecorder.onRecordingStopped = { [weak self] url in
|
screenRecorder.onRecordingSaved = { [weak self] url in
|
||||||
self?.showToast("Saved: \(url.lastPathComponent)")
|
self?.showToast("Saved: \(url.lastPathComponent)")
|
||||||
}
|
}
|
||||||
screenRecorder.onError = { [weak self] msg in
|
screenRecorder.onError = { [weak self] msg in
|
||||||
|
|||||||
@ -9,9 +9,13 @@ final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate {
|
|||||||
private var session: AVCaptureSession?
|
private var session: AVCaptureSession?
|
||||||
private var fileOutput: AVCaptureMovieFileOutput?
|
private var fileOutput: AVCaptureMovieFileOutput?
|
||||||
private var isConfigured = false
|
private var isConfigured = false
|
||||||
|
private var timer: Timer?
|
||||||
|
private var startDate: Date?
|
||||||
|
|
||||||
@Published var isRecording = false
|
@Published var isRecording = false
|
||||||
var onRecordingStopped: ((URL) -> Void)?
|
@Published var durationText = "00:00"
|
||||||
|
|
||||||
|
var onRecordingSaved: ((URL) -> Void)?
|
||||||
var onError: ((String) -> Void)?
|
var onError: ((String) -> Void)?
|
||||||
|
|
||||||
/// 保存目录
|
/// 保存目录
|
||||||
@ -51,12 +55,66 @@ final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate {
|
|||||||
fileOutput.startRecording(to: outputURL, recordingDelegate: self)
|
fileOutput.startRecording(to: outputURL, recordingDelegate: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止录制
|
/// 停止录制 → 弹出保存对话框
|
||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
guard isRecording, let fileOutput = fileOutput else { return }
|
guard isRecording, let fileOutput = fileOutput else { return }
|
||||||
fileOutput.stopRecording()
|
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 {
|
private func configureSession() -> Bool {
|
||||||
let session = AVCaptureSession()
|
let session = AVCaptureSession()
|
||||||
session.sessionPreset = .hd1920x1080
|
session.sessionPreset = .hd1920x1080
|
||||||
@ -89,7 +147,6 @@ final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate {
|
|||||||
session.addInput(audioInput)
|
session.addInput(audioInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 没有麦克风也能录制(只有画面)
|
|
||||||
|
|
||||||
// 文件输出
|
// 文件输出
|
||||||
let output = AVCaptureMovieFileOutput()
|
let output = AVCaptureMovieFileOutput()
|
||||||
@ -103,8 +160,7 @@ final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate {
|
|||||||
self.session = session
|
self.session = session
|
||||||
self.fileOutput = output
|
self.fileOutput = output
|
||||||
|
|
||||||
// 请求屏幕录制权限(macOS 10.15+)
|
// 请求屏幕录制权限
|
||||||
// CGRequestScreenCaptureAccess 在首次调用时触发权限弹窗
|
|
||||||
#if canImport(ScreenCaptureKit)
|
#if canImport(ScreenCaptureKit)
|
||||||
if !CGPreflightScreenCaptureAccess() {
|
if !CGPreflightScreenCaptureAccess() {
|
||||||
CGRequestScreenCaptureAccess()
|
CGRequestScreenCaptureAccess()
|
||||||
@ -118,18 +174,19 @@ final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate {
|
|||||||
nonisolated func fileOutputRecordingDidStart(_ output: AVCaptureFileOutput) {
|
nonisolated func fileOutputRecordingDidStart(_ output: AVCaptureFileOutput) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.isRecording = true
|
self.isRecording = true
|
||||||
|
self.startTimer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
|
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.isRecording = false
|
self.isRecording = false
|
||||||
|
self.stopTimer()
|
||||||
if let error = error {
|
if let error = error {
|
||||||
self.onError?("Recording failed: \(error.localizedDescription)")
|
self.onError?("Recording failed: \(error.localizedDescription)")
|
||||||
} else {
|
} else {
|
||||||
self.onRecordingStopped?(outputFileURL)
|
self.showSaveDialog(tempURL: outputFileURL)
|
||||||
}
|
}
|
||||||
// 停止 session 释放资源
|
|
||||||
self.session?.stopRunning()
|
self.session?.stopRunning()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user