diff --git a/.gitignore b/.gitignore index edb6705..929a22e 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,9 @@ Secrets.swift *.xcuserstate ScreenPresenter.xcodeproj/xcuserdata ScreenPresenter.xcodeproj/xcuserdata + +# ============================================================================= +# Agent workspace +# ============================================================================= +.superpowers/ +docs/ diff --git a/ScreenPresenter.xcodeproj/project.pbxproj b/ScreenPresenter.xcodeproj/project.pbxproj index d9d8703..e51d053 100644 --- a/ScreenPresenter.xcodeproj/project.pbxproj +++ b/ScreenPresenter.xcodeproj/project.pbxproj @@ -77,6 +77,9 @@ G1000001000000000004 /* ScrcpyVideoStreamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = G1000002000000000004 /* ScrcpyVideoStreamParser.swift */; }; G1000001000000000005 /* VideoToolboxDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = G1000002000000000005 /* VideoToolboxDecoder.swift */; }; G60942986538400935988421 /* ScrcpyErrorHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = G34952747508024177339802 /* ScrcpyErrorHelper.swift */; }; + R0000001000000000001 /* RecordingFileNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = R0000002000000000001 /* RecordingFileNaming.swift */; }; + R0000001000000000002 /* RecordingFrameSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = R0000002000000000002 /* RecordingFrameSnapshot.swift */; }; + R0000001000000000003 /* RecordingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = R0000002000000000003 /* RecordingService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -160,6 +163,9 @@ G1000002000000000004 /* ScrcpyVideoStreamParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrcpyVideoStreamParser.swift; sourceTree = ""; }; G1000002000000000005 /* VideoToolboxDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoToolboxDecoder.swift; sourceTree = ""; }; G34952747508024177339802 /* ScrcpyErrorHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrcpyErrorHelper.swift; sourceTree = ""; }; + R0000002000000000001 /* RecordingFileNaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingFileNaming.swift; sourceTree = ""; }; + R0000002000000000002 /* RecordingFrameSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingFrameSnapshot.swift; sourceTree = ""; }; + R0000002000000000003 /* RecordingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -290,6 +296,16 @@ path = Rendering; sourceTree = ""; }; + R0000003000000000001 /* Recording */ = { + isa = PBXGroup; + children = ( + R0000002000000000001 /* RecordingFileNaming.swift */, + R0000002000000000002 /* RecordingFrameSnapshot.swift */, + R0000002000000000003 /* RecordingService.swift */, + ); + path = Recording; + sourceTree = ""; + }; A44133202EF93201003DCDD3 /* Utilities */ = { isa = PBXGroup; children = ( @@ -315,6 +331,7 @@ A44133092EF93201003DCDD3 /* DeviceSource */, A441330F2EF93201003DCDD3 /* Preferences */, A44133122EF93201003DCDD3 /* Process */, + R0000003000000000001 /* Recording */, A441331E2EF93201003DCDD3 /* Rendering */, A44133202EF93201003DCDD3 /* Utilities */, ); @@ -538,6 +555,9 @@ C0025E2AB0327BD165380C0C /* FrameBuffer.swift in Sources */, A481F7992F0B579F00D9DAB0 /* FramePipeline.swift in Sources */, 2A000001000000000001 /* AudioPlayer.swift in Sources */, + R0000001000000000001 /* RecordingFileNaming.swift in Sources */, + R0000001000000000002 /* RecordingFrameSnapshot.swift in Sources */, + R0000001000000000003 /* RecordingService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ScreenPresenter/AppDelegate.swift b/ScreenPresenter/AppDelegate.swift index 455aa97..738bfbb 100644 --- a/ScreenPresenter/AppDelegate.swift +++ b/ScreenPresenter/AppDelegate.swift @@ -10,6 +10,7 @@ import AppKit import AVFoundation +import Combine import MarkdownEditor // MARK: - 应用程序委托 @@ -28,8 +29,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { private var preventSleepToolbarItem: NSToolbarItem? private var layoutModeToolbarItem: NSToolbarItem? private var layoutModeSegmentedControl: NSSegmentedControl? + private var recordingToolbarItem: NSToolbarItem? + private var recordingOutputToolbarItem: NSToolbarItem? + private var recordingOutputButton: NSButton? + private var recordingOutputWidthConstraint: NSLayoutConstraint? private var markdownToggleToolbarItem: NSToolbarItem? private var isRefreshing: Bool = false + private var cancellables = Set() // MARK: - 菜单项 @@ -56,6 +62,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { // MARK: - 应用生命周期 + @MainActor func applicationDidFinishLaunching(_ notification: Notification) { AppLogger.app.info("应用启动") @@ -144,6 +151,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { updateLayoutModeToolbarState() } + @MainActor @objc private func handleLanguageChange() { // 重建主菜单 setupMainMenu() @@ -160,6 +168,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { mainViewController?.updateLocalizedTexts() } + @MainActor private func rebuildWindowToolbar(for window: NSWindow) { // 移除旧工具栏 window.toolbar = nil @@ -168,10 +177,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { preventSleepToolbarItem = nil layoutModeToolbarItem = nil layoutModeSegmentedControl = nil + recordingToolbarItem = nil + recordingOutputToolbarItem = nil + recordingOutputButton = nil + recordingOutputWidthConstraint = nil markdownToggleToolbarItem = nil // 创建新工具栏 setupWindowToolbar(for: window) + updateRecordingUI() } /// 请求摄像头权限 @@ -1153,6 +1167,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { } // MARK: - 窗口设置 + @MainActor private func setupMainWindow() { // 创建主视图控制器 mainViewController = MainViewController() @@ -1175,6 +1190,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { // 设置窗口工具栏 setupWindowToolbar(for: window) + setupRecordingObservation() // 设置窗口代理 window.delegate = self @@ -1199,6 +1215,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { window.toolbar = toolbar windowToolbar = toolbar } + + @MainActor + private func setupRecordingObservation() { + AppState.shared.recordingService.stateChangedPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.updateRecordingUI() + } + .store(in: &cancellables) + } } // MARK: - 窗口代理 @@ -1471,6 +1497,46 @@ extension AppDelegate { ToastView.success(L10n.toolbar.refreshComplete, in: mainWindow) } + // MARK: - 录制操作 + + @IBAction func toggleRecording(_ sender: Any?) { + let recordingService = AppState.shared.recordingService + + if recordingService.state.isRecording { + let directory = recordingService.stopRecording() + AppState.shared.iosDeviceSource?.stopAudioCaptureForCurrentSession() + updateRecordingUI() + if directory != nil { + ToastView.success(L10n.recording.saved, in: mainWindow) + } + return + } + + Task { + do { + try await recordingService.startRecording() + await MainActor.run { + updateRecordingUI() + ToastView.success(L10n.recording.started, in: mainWindow) + } + } catch { + await MainActor.run { + updateRecordingUI() + ToastView.error(error.localizedDescription, in: mainWindow) + } + } + } + } + + @MainActor + @objc private func openRecordingOutputDirectory(_ sender: Any?) { + guard let directory = AppState.shared.recordingService.lastOutputDirectory else { + NSSound.beep() + return + } + NSWorkspace.shared.activateFileViewerSelecting([directory]) + } + // MARK: - 面板交换操作 @IBAction func swapPanels(_ sender: Any?) { @@ -1757,6 +1823,70 @@ extension AppDelegate { item.image = image } + @MainActor + private func updateRecordingUI() { + let service = AppState.shared.recordingService + let isRecording = service.state.isRecording + let shouldShowOutput = service.lastOutputDirectory != nil && !isRecording + + if let item = recordingToolbarItem { + updateRecordingToolbarItemImage(item) + } + + updateRecordingOutputToolbarPresence(shouldShowOutput) + + if let outputButton = recordingOutputButton { + if let directory = service.lastOutputDirectory, shouldShowOutput { + outputButton.title = L10n.recording.savedLocation( + (directory.path as NSString).abbreviatingWithTildeInPath + ) + outputButton.toolTip = L10n.recording.openOutputDirectory + outputButton.isHidden = false + recordingOutputWidthConstraint?.constant = 320 + } else { + outputButton.title = "" + outputButton.toolTip = nil + outputButton.isHidden = true + recordingOutputWidthConstraint?.constant = 0 + } + } + + mainViewController?.setRecordingIndicatorVisible(isRecording, elapsedSeconds: service.elapsedSeconds) + } + + @MainActor + private func updateRecordingOutputToolbarPresence(_ shouldShow: Bool) { + guard let toolbar = windowToolbar else { return } + + let items = toolbar.items + let outputIndex = items.firstIndex { $0.itemIdentifier == ToolbarItemIdentifier.recordingOutput } + + if shouldShow { + guard outputIndex == nil else { return } + + let recordingIndex = items.firstIndex { $0.itemIdentifier == ToolbarItemIdentifier.recording } ?? 0 + toolbar.insertItem(withItemIdentifier: ToolbarItemIdentifier.recordingOutput, at: recordingIndex) + } else if let outputIndex { + toolbar.removeItem(at: outputIndex) + recordingOutputToolbarItem = nil + recordingOutputButton = nil + recordingOutputWidthConstraint = nil + } + } + + @MainActor + private func updateRecordingToolbarItemImage(_ item: NSToolbarItem) { + let isRecording = AppState.shared.recordingService.state.isRecording + let symbolName = isRecording ? "stop.circle.fill" : "record.circle" + item.label = isRecording ? L10n.recording.stop : L10n.recording.record + item.paletteLabel = L10n.recording.record + item.toolTip = isRecording ? L10n.recording.stop : L10n.recording.record + item.image = NSImage( + systemSymbolName: symbolName, + accessibilityDescription: item.toolTip + ) + } + private func updateLayoutModeToolbarState() { guard let segmentedControl = layoutModeSegmentedControl else { return } let currentMode = UserPreferences.shared.layoutMode @@ -1797,6 +1927,8 @@ extension AppDelegate: NSMenuDelegate { extension AppDelegate: NSToolbarDelegate { private enum ToolbarItemIdentifier { + static let recording = NSToolbarItem.Identifier("recording") + static let recordingOutput = NSToolbarItem.Identifier("recordingOutput") static let layoutMode = NSToolbarItem.Identifier("layoutMode") static let refresh = NSToolbarItem.Identifier("refresh") static let toggleBezel = NSToolbarItem.Identifier("toggleBezel") @@ -1808,6 +1940,7 @@ extension AppDelegate: NSToolbarDelegate { func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [ + ToolbarItemIdentifier.recording, ToolbarItemIdentifier.markdownToggle, .space, ToolbarItemIdentifier.layoutMode, @@ -1820,6 +1953,8 @@ extension AppDelegate: NSToolbarDelegate { func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [ + ToolbarItemIdentifier.recording, + ToolbarItemIdentifier.recordingOutput, ToolbarItemIdentifier.refresh, ToolbarItemIdentifier.markdownToggle, ToolbarItemIdentifier.layoutMode, @@ -1835,6 +1970,39 @@ extension AppDelegate: NSToolbarDelegate { willBeInsertedIntoToolbar flag: Bool ) -> NSToolbarItem? { switch itemIdentifier { + case ToolbarItemIdentifier.recording: + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.target = self + item.action = #selector(toggleRecording(_:)) + recordingToolbarItem = item + updateRecordingToolbarItemImage(item) + return item + + case ToolbarItemIdentifier.recordingOutput: + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.label = L10n.recording.saved + item.paletteLabel = L10n.recording.saved + + let button = NSButton(title: "", target: self, action: #selector(openRecordingOutputDirectory(_:))) + button.bezelStyle = .rounded + button.isBordered = true + button.font = NSFont.systemFont(ofSize: 12) + button.lineBreakMode = .byTruncatingMiddle + button.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + button.translatesAutoresizingMaskIntoConstraints = false + button.isHidden = true + + let widthConstraint = button.widthAnchor.constraint(equalToConstant: 0) + let heightConstraint = button.heightAnchor.constraint(equalToConstant: 28) + widthConstraint.isActive = true + heightConstraint.isActive = true + + item.view = button + recordingOutputToolbarItem = item + recordingOutputButton = button + recordingOutputWidthConstraint = widthConstraint + return item + case ToolbarItemIdentifier.layoutMode: let item = NSToolbarItem(itemIdentifier: itemIdentifier) item.label = L10n.toolbar.layoutMode diff --git a/ScreenPresenter/Core/AppState.swift b/ScreenPresenter/Core/AppState.swift index 27102a9..641cfc8 100644 --- a/ScreenPresenter/Core/AppState.swift +++ b/ScreenPresenter/Core/AppState.swift @@ -39,6 +39,11 @@ final class AppState { /// Android 设备源 private(set) var androidDeviceSource: ScrcpyDeviceSource? + /// 录制服务 + private(set) lazy var recordingService = RecordingService { [weak self] in + self?.currentRecordingFrameSnapshots() ?? [] + } + /// 是否正在初始化 private(set) var isInitializing = true @@ -104,6 +109,7 @@ final class AppState { AppLogger.app.info("开始清理资源") deviceObservationTask?.cancel() + recordingService.stopRecording() // 断开所有设备 if let source = iosDeviceSource { @@ -301,6 +307,36 @@ final class AppState { } } + /// 获取当前可用于录制截图的投屏帧。 + /// 录制服务每秒调用这里一次,因此录制开始后才启动投屏的设备也会自动加入。 + private func currentRecordingFrameSnapshots() -> [RecordingFrameSnapshot] { + var snapshots: [RecordingFrameSnapshot] = [] + + if + let source = iosDeviceSource, + source.state == .capturing, + let pixelBuffer = source.latestFrame?.pixelBuffer { + snapshots.append(RecordingFrameSnapshot( + platform: .ios, + deviceName: currentIOSDevice?.displayName ?? L10n.platform.ios, + pixelBuffer: pixelBuffer + )) + } + + if + let source = androidDeviceSource, + source.state == .capturing, + let pixelBuffer = source.latestPixelBuffer ?? source.latestFrame?.pixelBuffer { + snapshots.append(RecordingFrameSnapshot( + platform: .android, + deviceName: currentAndroidDevice?.displayName ?? L10n.platform.android, + pixelBuffer: pixelBuffer + )) + } + + return snapshots + } + // MARK: - 计算属性 /// 当前 iOS 设备(用于获取完整设备信息) diff --git a/ScreenPresenter/Core/DeviceSource/IOSDeviceSource.swift b/ScreenPresenter/Core/DeviceSource/IOSDeviceSource.swift index f673391..ee09792 100644 --- a/ScreenPresenter/Core/DeviceSource/IOSDeviceSource.swift +++ b/ScreenPresenter/Core/DeviceSource/IOSDeviceSource.swift @@ -47,6 +47,14 @@ final class IOSDeviceSource: BaseDeviceSource, @unchecked Sendable { /// 音频播放器 private var audioPlayer: AudioPlayer? + /// 当前捕获会话中的 iOS 音频开关状态 + /// 说明:停止录制时需要临时释放系统音频输入路径,但不能改写用户的持久偏好。 + private var audioEnabledForCurrentSession = false + + /// 可用于音频捕获的 iOS CoreMediaIO 设备 + /// 说明:iOS 投屏设备的音频和视频通常来自同一个 muxed 设备,保存引用用于运行时开关音频输出。 + private var audioCaptureDevice: AVCaptureDevice? + /// 是否正在捕获(使用线程安全的原子操作) private let capturingLock = OSAllocatedUnfairLock(initialState: false) @@ -55,12 +63,13 @@ final class IOSDeviceSource: BaseDeviceSource, @unchecked Sendable { // MARK: - 音频控制 - /// 是否启用音频(从偏好设置读取) + /// 是否启用当前会话的 iOS 音频 var isAudioEnabled: Bool { - get { UserPreferences.shared.iosAudioEnabled } + get { audioEnabledForCurrentSession } set { + audioEnabledForCurrentSession = newValue UserPreferences.shared.iosAudioEnabled = newValue - updateAudioPlayback() + setAudioCaptureEnabled(newValue) } } @@ -93,6 +102,7 @@ final class IOSDeviceSource: BaseDeviceSource, @unchecked Sendable { self.deviceInfo = deviceInfo AppLogger.device.info("创建 iOS 设备源: \(device.name)") + audioEnabledForCurrentSession = UserPreferences.shared.iosAudioEnabled } // MARK: - DeviceSource 实现 @@ -142,6 +152,7 @@ final class IOSDeviceSource: BaseDeviceSource, @unchecked Sendable { audioOutput = nil videoDelegate = nil audioDelegate = nil + audioCaptureDevice = nil onFrame = nil lastCaptureSize = .zero @@ -297,6 +308,7 @@ final class IOSDeviceSource: BaseDeviceSource, @unchecked Sendable { // 检查是否支持 muxed 或 audio let supportsMuxed = videoDevice.hasMediaType(.muxed) let supportsAudio = videoDevice.hasMediaType(.audio) + audioCaptureDevice = videoDevice AppLogger.capture.info("[Audio] 设备音频支持: muxed=\(supportsMuxed), audio=\(supportsAudio)") @@ -305,6 +317,16 @@ final class IOSDeviceSource: BaseDeviceSource, @unchecked Sendable { return } + guard isAudioEnabled else { + AppLogger.capture.info("[Audio] iOS 音频开关关闭,跳过音频输出") + return + } + + guard audioOutput == nil else { + AppLogger.capture.info("[Audio] 音频输出已存在,跳过重复添加") + return + } + // 直接添加音频输出到会话 // 对于 muxed 设备,音频和视频共享同一个输入,但可以有独立的输出 let audioOutput = AVCaptureAudioDataOutput() @@ -349,7 +371,63 @@ final class IOSDeviceSource: BaseDeviceSource, @unchecked Sendable { /// 更新音频播放状态 private func updateAudioPlayback() { - audioPlayer?.isMuted = !isAudioEnabled + audioPlayer?.isMuted = !audioEnabledForCurrentSession + } + + /// 仅停止当前捕获会话的 iOS 音频,不改写用户偏好。 + /// 停止录制时用于释放系统音频输入路径,避免下次启动时丢失用户原本的音频偏好。 + func stopAudioCaptureForCurrentSession() { + audioEnabledForCurrentSession = false + setAudioCaptureEnabled(false) + } + + /// 运行时切换 iOS 设备音频捕获 + /// 说明:只静音播放器不能释放系统音频输入路径,必须从 AVCaptureSession 中移除音频输出。 + private func setAudioCaptureEnabled(_ enabled: Bool) { + guard let session = captureSession else { + updateAudioPlayback() + return + } + + captureQueue.async { [weak self] in + guard let self else { return } + + if enabled { + guard let audioCaptureDevice else { + AppLogger.capture.warning("[Audio] 缺少 iOS 音频设备,无法启用音频捕获") + return + } + + session.beginConfiguration() + self.setupAudioCapture(for: session, videoDevice: audioCaptureDevice) + session.commitConfiguration() + } else { + self.removeAudioCaptureOutput(from: session) + } + } + } + + /// 移除音频输出并停止播放器 + /// 说明:停止录制或关闭 iOS 音频时调用,确保系统不再把 ScreenPresenter 视为音频输入占用方。 + private func removeAudioCaptureOutput(from session: AVCaptureSession) { + guard let audioOutput else { + audioPlayer?.stop() + audioPlayer = nil + audioDelegate = nil + return + } + + session.beginConfiguration() + audioOutput.setSampleBufferDelegate(nil, queue: nil) + session.removeOutput(audioOutput) + session.commitConfiguration() + + self.audioOutput = nil + audioDelegate = nil + audioPlayer?.stop() + audioPlayer = nil + + AppLogger.capture.info("[Audio] iOS 音频捕获已关闭") } // MARK: - 帧处理 diff --git a/ScreenPresenter/Core/Recording/RecordingFileNaming.swift b/ScreenPresenter/Core/Recording/RecordingFileNaming.swift new file mode 100644 index 0000000..62d532e --- /dev/null +++ b/ScreenPresenter/Core/Recording/RecordingFileNaming.swift @@ -0,0 +1,60 @@ +// +// RecordingFileNaming.swift +// ScreenPresenter +// +// Created by Sun on 2026/06/30. +// +// 录制文件命名工具 +// 统一生成会话目录、设备目录和截图文件名 +// + +import Foundation + +// MARK: - 录制文件命名 + +enum RecordingFileNaming { + private static let invalidFileNameCharacters = CharacterSet(charactersIn: "/\\?%*|\"<>:") + + private static let sessionFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH-mm-ss" + return formatter + }() + + private static let snapshotFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + return formatter + }() + + static func sessionDirectoryName(date: Date) -> String { + sessionFormatter.string(from: date) + } + + static func deviceDirectoryName(platformName: String, deviceName: String) -> String { + let sanitizedName = sanitizeFileNameComponent(deviceName) + return "\(platformName)-\(sanitizedName)" + } + + static func snapshotFileName(elapsedSecond: Int, date: Date) -> String { + let clampedSecond = max(0, elapsedSecond) + let elapsed = String(format: "%06d", clampedSecond) + return "\(elapsed)_\(snapshotFormatter.string(from: date)).jpg" + } + + static func sanitizeFileNameComponent(_ value: String) -> String { + let scalars = value.unicodeScalars.map { scalar -> String in + invalidFileNameCharacters.contains(scalar) || CharacterSet.newlines.contains(scalar) + ? "-" + : String(scalar) + } + + let collapsed = scalars.joined() + .replacingOccurrences(of: #"-+"#, with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-. ")) + + return collapsed.isEmpty ? "Unknown" : collapsed + } +} diff --git a/ScreenPresenter/Core/Recording/RecordingFrameSnapshot.swift b/ScreenPresenter/Core/Recording/RecordingFrameSnapshot.swift new file mode 100644 index 0000000..2ae1770 --- /dev/null +++ b/ScreenPresenter/Core/Recording/RecordingFrameSnapshot.swift @@ -0,0 +1,20 @@ +// +// RecordingFrameSnapshot.swift +// ScreenPresenter +// +// Created by Sun on 2026/06/30. +// +// 录制截图快照 +// 描述当前可保存截图的一台投屏设备 +// + +import CoreVideo +import Foundation + +// MARK: - 录制截图快照 + +struct RecordingFrameSnapshot { + let platform: DevicePlatform + let deviceName: String + let pixelBuffer: CVPixelBuffer +} diff --git a/ScreenPresenter/Core/Recording/RecordingService.swift b/ScreenPresenter/Core/Recording/RecordingService.swift new file mode 100644 index 0000000..7dc27ec --- /dev/null +++ b/ScreenPresenter/Core/Recording/RecordingService.swift @@ -0,0 +1,366 @@ +// +// RecordingService.swift +// ScreenPresenter +// +// Created by Sun on 2026/06/30. +// +// 录制服务 +// 负责录制 Mac 麦克风并按秒保存投屏设备截图 +// + +import AVFoundation +import Combine +import CoreImage +import Foundation +import ImageIO +import UniformTypeIdentifiers + +// MARK: - 录制状态 + +enum RecordingState: Equatable { + case idle + case recording(startedAt: Date, outputDirectory: URL) + case failed(String) + + var isRecording: Bool { + if case .recording = self { + return true + } + return false + } +} + +enum RecordingServiceError: LocalizedError { + case microphonePermissionDenied + case recorderStartFailed + case outputDirectoryUnavailable + + var errorDescription: String? { + switch self { + case .microphonePermissionDenied: + L10n.recording.microphonePermissionDenied + case .recorderStartFailed: + L10n.recording.startFailed + case .outputDirectoryUnavailable: + L10n.recording.outputDirectoryUnavailable + } + } +} + +// MARK: - 录制服务 + +@MainActor +final class RecordingService: NSObject { + typealias FrameProvider = () -> [RecordingFrameSnapshot] + + // MARK: - 发布者 + + let stateChangedPublisher = PassthroughSubject() + + // MARK: - 状态 + + private(set) var state: RecordingState = .idle { + didSet { stateChangedPublisher.send() } + } + + private(set) var elapsedSeconds: Int = 0 { + didSet { stateChangedPublisher.send() } + } + + private(set) var lastOutputDirectory: URL? { + didSet { stateChangedPublisher.send() } + } + + // MARK: - 私有属性 + + private let frameProvider: FrameProvider + private let fileManager: FileManager + private let imageContext = CIContext(options: [.useSoftwareRenderer: false]) + + private var audioRecorder: AVAudioRecorder? + private var snapshotTimer: Timer? + private var elapsedTimer: Timer? + private var startedAt: Date? + private var outputDirectory: URL? + private var deviceDirectories: [String: URL] = [:] + + private let maxSnapshotLongSide: CGFloat = 1280 + private let jpegQuality: CGFloat = 0.65 + + // MARK: - 初始化 + + init(fileManager: FileManager = .default, frameProvider: @escaping FrameProvider) { + self.fileManager = fileManager + self.frameProvider = frameProvider + super.init() + } + + deinit { + snapshotTimer?.invalidate() + elapsedTimer?.invalidate() + audioRecorder?.stop() + } + + // MARK: - 公开方法 + + func startRecording() async throws { + guard !state.isRecording else { return } + + lastOutputDirectory = nil + elapsedSeconds = 0 + deviceDirectories.removeAll() + + do { + try await ensureMicrophoneAccess() + + let now = Date() + let directory = try createSessionDirectory(startedAt: now) + let audioURL = directory.appendingPathComponent("audio.m4a") + let recorder = try makeAudioRecorder(outputURL: audioURL) + + guard recorder.record() else { + throw RecordingServiceError.recorderStartFailed + } + + startedAt = now + outputDirectory = directory + audioRecorder = recorder + state = .recording(startedAt: now, outputDirectory: directory) + + startTimers() + AppLogger.capture.info("录制已开始: \(directory.path)") + } catch { + cleanupAfterFailedStart() + state = .failed(error.localizedDescription) + throw error + } + } + + @discardableResult + func stopRecording() -> URL? { + let wasRecording = state.isRecording + + stopTimers() + stopAudioRecorder() + + let directory = outputDirectory + outputDirectory = nil + startedAt = nil + deviceDirectories.removeAll() + + state = .idle + if let directory { + lastOutputDirectory = directory + } + + if wasRecording, let directory { + AppLogger.capture.info("录制已停止: \(directory.path)") + } + + return directory ?? lastOutputDirectory + } + + // MARK: - 权限和录音 + + private func ensureMicrophoneAccess() async throws { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + return + case .notDetermined: + let granted = await requestMicrophoneAccess() + guard granted else { + throw RecordingServiceError.microphonePermissionDenied + } + case .denied, .restricted: + throw RecordingServiceError.microphonePermissionDenied + @unknown default: + throw RecordingServiceError.microphonePermissionDenied + } + } + + private func requestMicrophoneAccess() async -> Bool { + await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { granted in + continuation.resume(returning: granted) + } + } + } + + private func makeAudioRecorder(outputURL: URL) throws -> AVAudioRecorder { + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44_100, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 64_000, + ] + + let recorder = try AVAudioRecorder(url: outputURL, settings: settings) + recorder.delegate = self + recorder.isMeteringEnabled = false + recorder.prepareToRecord() + return recorder + } + + // MARK: - 目录 + + private func createSessionDirectory(startedAt: Date) throws -> URL { + guard let moviesDirectory = fileManager.urls(for: .moviesDirectory, in: .userDomainMask).first else { + throw RecordingServiceError.outputDirectoryUnavailable + } + + let rootDirectory = moviesDirectory.appendingPathComponent("ScreenPresenter Recordings", isDirectory: true) + let sessionDirectory = rootDirectory.appendingPathComponent( + RecordingFileNaming.sessionDirectoryName(date: startedAt), + isDirectory: true + ) + + try fileManager.createDirectory(at: sessionDirectory, withIntermediateDirectories: true) + return sessionDirectory + } + + private func directory(for snapshot: RecordingFrameSnapshot) throws -> URL { + let key = "\(snapshot.platform.rawValue)-\(snapshot.deviceName)" + if let directory = deviceDirectories[key] { + return directory + } + + guard let outputDirectory else { + throw RecordingServiceError.outputDirectoryUnavailable + } + + let directoryName = RecordingFileNaming.deviceDirectoryName( + platformName: snapshot.platform.rawValue, + deviceName: snapshot.deviceName + ) + let directory = outputDirectory.appendingPathComponent(directoryName, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + deviceDirectories[key] = directory + return directory + } + + // MARK: - 定时器 + + private func startTimers() { + stopTimers() + + let elapsedTimer = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.updateElapsedSeconds() + } + } + RunLoop.main.add(elapsedTimer, forMode: .common) + self.elapsedTimer = elapsedTimer + + let snapshotTimer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.captureCurrentDeviceSnapshots() + } + } + RunLoop.main.add(snapshotTimer, forMode: .common) + self.snapshotTimer = snapshotTimer + } + + private func updateElapsedSeconds() { + guard let startedAt else { + elapsedSeconds = 0 + return + } + elapsedSeconds = max(0, Int(Date().timeIntervalSince(startedAt))) + } + + // MARK: - 截图 + + private func captureCurrentDeviceSnapshots() { + guard state.isRecording, let startedAt else { return } + + let now = Date() + let elapsedSecond = max(1, Int(now.timeIntervalSince(startedAt))) + elapsedSeconds = elapsedSecond + + for snapshot in frameProvider() { + do { + let deviceDirectory = try directory(for: snapshot) + let fileName = RecordingFileNaming.snapshotFileName(elapsedSecond: elapsedSecond, date: now) + let outputURL = deviceDirectory.appendingPathComponent(fileName) + try writeJPEG(pixelBuffer: snapshot.pixelBuffer, to: outputURL) + } catch { + AppLogger.capture.warning("保存录制截图失败: \(error.localizedDescription)") + } + } + } + + private func writeJPEG(pixelBuffer: CVPixelBuffer, to outputURL: URL) throws { + let image = CIImage(cvImageBuffer: pixelBuffer) + let scale = snapshotScale(for: image.extent.size) + let outputImage = scale < 1 + ? image.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + : image + + guard let cgImage = imageContext.createCGImage(outputImage, from: outputImage.extent) else { + throw RecordingServiceError.outputDirectoryUnavailable + } + + guard let destination = CGImageDestinationCreateWithURL( + outputURL as CFURL, + UTType.jpeg.identifier as CFString, + 1, + nil + ) else { + throw RecordingServiceError.outputDirectoryUnavailable + } + + let options: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: jpegQuality, + ] + CGImageDestinationAddImage(destination, cgImage, options as CFDictionary) + + if !CGImageDestinationFinalize(destination) { + throw RecordingServiceError.outputDirectoryUnavailable + } + } + + private func snapshotScale(for size: CGSize) -> CGFloat { + let longSide = max(size.width, size.height) + guard longSide > maxSnapshotLongSide else { return 1 } + return maxSnapshotLongSide / longSide + } + + private func cleanupAfterFailedStart() { + stopTimers() + stopAudioRecorder() + + startedAt = nil + outputDirectory = nil + deviceDirectories.removeAll() + elapsedSeconds = 0 + } + + private func stopTimers() { + snapshotTimer?.invalidate() + elapsedTimer?.invalidate() + snapshotTimer = nil + elapsedTimer = nil + } + + private func stopAudioRecorder() { + guard let recorder = audioRecorder else { return } + recorder.stop() + recorder.delegate = nil + audioRecorder = nil + } +} + +// MARK: - AVAudioRecorderDelegate + +extension RecordingService: AVAudioRecorderDelegate { + nonisolated func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + Task { @MainActor in + let message = error?.localizedDescription ?? L10n.recording.startFailed + AppLogger.capture.error("录制音频编码失败: \(message)") + _ = stopRecording() + state = .failed(message) + } + } +} diff --git a/ScreenPresenter/Core/Utilities/Localization.swift b/ScreenPresenter/Core/Utilities/Localization.swift index d652d4d..2ae4bd6 100644 --- a/ScreenPresenter/Core/Utilities/Localization.swift +++ b/ScreenPresenter/Core/Utilities/Localization.swift @@ -531,6 +531,21 @@ enum L10n { static var markdownEditorTooltip: String { "toolbar.markdownEditorTooltip".localized } } + // MARK: - Recording + + enum recording { + static var record: String { "recording.record".localized } + static var stop: String { "recording.stop".localized } + static var started: String { "recording.started".localized } + static var saved: String { "recording.saved".localized } + static var startFailed: String { "recording.startFailed".localized } + static var microphonePermissionDenied: String { "recording.microphonePermissionDenied".localized } + static var outputDirectoryUnavailable: String { "recording.outputDirectoryUnavailable".localized } + static var openOutputDirectory: String { "recording.openOutputDirectory".localized } + static func savedLocation(_ path: String) -> String { "recording.savedLocation".localized(path) } + static func recordingWithElapsed(_ elapsed: String) -> String { "recording.recordingWithElapsed".localized(elapsed) } + } + // MARK: - Toast enum toast { diff --git a/ScreenPresenter/Info.plist b/ScreenPresenter/Info.plist index c45a7ca..83a792e 100644 --- a/ScreenPresenter/Info.plist +++ b/ScreenPresenter/Info.plist @@ -6,6 +6,8 @@ LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + NSMicrophoneUsageDescription + ScreenPresenter records the Mac microphone while a recording session is active. SUEnableAutomaticChecks SUFeedURL diff --git a/ScreenPresenter/Resources/en.lproj/Localizable.strings b/ScreenPresenter/Resources/en.lproj/Localizable.strings index 3d3bb52..a3e433b 100644 --- a/ScreenPresenter/Resources/en.lproj/Localizable.strings +++ b/ScreenPresenter/Resources/en.lproj/Localizable.strings @@ -437,6 +437,18 @@ "toast.exportLogsSuccess" = "Logs exported successfully"; "toast.exportLogsFailed" = "Failed to export logs: %@"; +// MARK: - Recording +"recording.record" = "Record"; +"recording.stop" = "Stop Recording"; +"recording.started" = "Recording started"; +"recording.saved" = "Recording saved"; +"recording.startFailed" = "Failed to start recording"; +"recording.microphonePermissionDenied" = "Microphone permission is disabled. Allow ScreenPresenter to access the microphone in System Settings."; +"recording.outputDirectoryUnavailable" = "Unable to create recording output directory"; +"recording.openOutputDirectory" = "Open recording folder"; +"recording.savedLocation" = "Saved: %@"; +"recording.recordingWithElapsed" = "Recording %@"; + // MARK: - Menu (Color) "menu.view" = "View"; "menu.colorCompensation" = "Color Compensation..."; diff --git a/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings b/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings index 07cf53e..8cab718 100644 --- a/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings +++ b/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings @@ -437,6 +437,18 @@ "toast.exportLogsSuccess" = "日志导出成功"; "toast.exportLogsFailed" = "日志导出失败: %@"; +// MARK: - Recording +"recording.record" = "录制"; +"recording.stop" = "停止录制"; +"recording.started" = "录制已开始"; +"recording.saved" = "录制已保存"; +"recording.startFailed" = "录制开始失败"; +"recording.microphonePermissionDenied" = "麦克风权限未开启,请在系统设置中允许 ScreenPresenter 访问麦克风"; +"recording.outputDirectoryUnavailable" = "无法创建录制保存目录"; +"recording.openOutputDirectory" = "打开录制目录"; +"recording.savedLocation" = "已保存:%@"; +"recording.recordingWithElapsed" = "录制中 %@"; + // MARK: - Menu (Color) "menu.view" = "显示"; "menu.colorCompensation" = "颜色补偿..."; diff --git a/ScreenPresenter/Views/Components/DeviceCaptureInfoView.swift b/ScreenPresenter/Views/Components/DeviceCaptureInfoView.swift index 0ca5f90..6a010e2 100644 --- a/ScreenPresenter/Views/Components/DeviceCaptureInfoView.swift +++ b/ScreenPresenter/Views/Components/DeviceCaptureInfoView.swift @@ -529,14 +529,17 @@ final class DeviceCaptureInfoView: NSView { audioControlContainer.isHidden = !shouldShowAudioControls if shouldShowAudioControls { - // 运行期偏好设置更改需重启生效,这里仅使用当前会话的音频启用状态(已为 true) + // 是否显示控件由启动时快照决定;控件状态使用当前偏好,避免关闭音频后仍显示为开启。 + let enabled: Bool let volume: Float if platform == .ios { + enabled = AppState.shared.iosDeviceSource?.isAudioEnabled ?? UserPreferences.shared.iosAudioEnabled volume = UserPreferences.shared.iosAudioVolume } else { + enabled = UserPreferences.shared.androidAudioEnabled volume = UserPreferences.shared.androidAudioVolume } - updateAudioState(enabled: true, volume: volume) + updateAudioState(enabled: enabled, volume: volume) } else { showsVolumeControls = false volumeSlider.isHidden = true diff --git a/ScreenPresenter/Views/Components/PreviewContainerView.swift b/ScreenPresenter/Views/Components/PreviewContainerView.swift index 010d7c2..f5c2422 100644 --- a/ScreenPresenter/Views/Components/PreviewContainerView.swift +++ b/ScreenPresenter/Views/Components/PreviewContainerView.swift @@ -265,6 +265,11 @@ final class PreviewContainerView: NSView, NSTabViewDelegate { return button }() + /// 录制中状态标识(覆盖在整个投屏区域左上角) + private let recordingIndicatorView = NSView() + private let recordingIndicatorDotLayer = CALayer() + private let recordingIndicatorLabel = NSTextField(labelWithString: "") + // MARK: - 状态 /// 当前布局模式 @@ -342,6 +347,7 @@ final class PreviewContainerView: NSView, NSTabViewDelegate { setupDevicePanels() setupSwapButton() setupPreviewToggleButton() + setupRecordingIndicator() updateMarkdownTabBarVisibilityForCurrentMode() startMarkdownUnsavedStateTimer() @@ -588,6 +594,22 @@ final class PreviewContainerView: NSView, NSTabViewDelegate { androidPanelView.updateLocalizedTexts() } + /// 更新录制状态标识。 + /// - Parameters: + /// - visible: 是否显示录制中标识 + /// - elapsedSeconds: 当前录制经过秒数 + func setRecordingIndicatorVisible(_ visible: Bool, elapsedSeconds: Int) { + recordingIndicatorView.isHidden = !visible + guard visible else { + recordingIndicatorDotLayer.removeAnimation(forKey: "recordingPulse") + return + } + + recordingIndicatorLabel.stringValue = L10n.recording.recordingWithElapsed(formatElapsedTime(elapsedSeconds)) + startRecordingPulseAnimationIfNeeded() + layoutRecordingIndicator() + } + // MARK: - 私有方法 /// 更新区域可见性和约束(无动画时使用) @@ -1159,6 +1181,101 @@ final class PreviewContainerView: NSView, NSTabViewDelegate { swapButtonIconLayer.frame = CGRect(x: iconOffset, y: iconOffset, width: iconSize, height: iconSize) } + private func setupRecordingIndicator() { + recordingIndicatorView.wantsLayer = true + recordingIndicatorView.layer?.cornerRadius = 8 + recordingIndicatorView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor + recordingIndicatorView.layer?.shadowColor = NSColor.black.cgColor + recordingIndicatorView.layer?.shadowOpacity = 0.18 + recordingIndicatorView.layer?.shadowOffset = CGSize(width: 0, height: -2) + recordingIndicatorView.layer?.shadowRadius = 8 + recordingIndicatorView.isHidden = true + + recordingIndicatorDotLayer.backgroundColor = NSColor.systemRed.cgColor + recordingIndicatorDotLayer.cornerRadius = 4 + recordingIndicatorView.layer?.addSublayer(recordingIndicatorDotLayer) + + recordingIndicatorLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .semibold) + recordingIndicatorLabel.textColor = .white + recordingIndicatorLabel.alignment = .left + recordingIndicatorLabel.lineBreakMode = .byTruncatingTail + recordingIndicatorLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + recordingIndicatorView.addSubview(recordingIndicatorLabel) + + addSubview(recordingIndicatorView) + } + + private func layoutRecordingIndicator() { + guard !recordingIndicatorView.isHidden else { return } + + let horizontalPadding: CGFloat = 10 + let verticalPadding: CGFloat = 7 + let dotSize: CGFloat = 8 + let spacing: CGFloat = 8 + let labelSize = recordingIndicatorLabelSize() + let width = min( + bounds.width - 24, + horizontalPadding * 2 + dotSize + spacing + labelSize.width + ) + let height = max(28, verticalPadding * 2 + labelSize.height) + let labelWidth = max(0, width - horizontalPadding * 2 - dotSize - spacing) + + recordingIndicatorView.frame = CGRect( + x: 12, + y: bounds.height - height - 12, + width: width, + height: height + ) + + recordingIndicatorDotLayer.frame = CGRect( + x: horizontalPadding, + y: (height - dotSize) / 2, + width: dotSize, + height: dotSize + ) + recordingIndicatorLabel.frame = CGRect( + x: horizontalPadding + dotSize + spacing, + y: (height - labelSize.height) / 2, + width: labelWidth, + height: labelSize.height + ) + } + + private func recordingIndicatorLabelSize() -> CGSize { + let font = recordingIndicatorLabel.font ?? NSFont.systemFont(ofSize: 12, weight: .semibold) + let attributes: [NSAttributedString.Key: Any] = [.font: font] + let textSize = (recordingIndicatorLabel.stringValue as NSString).size(withAttributes: attributes) + // `NSTextField.intrinsicContentSize` 在频繁更新时间文本时偶尔会偏紧,手动测量并给少量冗余。 + return CGSize(width: ceil(textSize.width) + 8, height: ceil(textSize.height)) + } + + private func startRecordingPulseAnimationIfNeeded() { + guard recordingIndicatorDotLayer.animation(forKey: "recordingPulse") == nil else { return } + + let opacity = CABasicAnimation(keyPath: "opacity") + opacity.fromValue = 0.95 + opacity.toValue = 0.45 + + let scale = CABasicAnimation(keyPath: "transform.scale") + scale.fromValue = 1.0 + scale.toValue = 1.18 + + let group = CAAnimationGroup() + group.animations = [opacity, scale] + group.duration = 1.4 + group.autoreverses = true + group.repeatCount = .infinity + group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + recordingIndicatorDotLayer.add(group, forKey: "recordingPulse") + } + + private func formatElapsedTime(_ seconds: Int) -> String { + let clamped = max(0, seconds) + let minutes = clamped / 60 + let remainingSeconds = clamped % 60 + return String(format: "%02d:%02d", minutes, remainingSeconds) + } + override func layout() { super.layout() // 动画期间不更新区域帧,避免覆盖动画初始状态 @@ -1173,6 +1290,7 @@ final class PreviewContainerView: NSView, NSTabViewDelegate { if isFullScreen, !previewToggleButton.isHidden { layoutPreviewToggleButton() } + layoutRecordingIndicator() } override func mouseDown(with event: NSEvent) { diff --git a/ScreenPresenter/Views/MainViewController.swift b/ScreenPresenter/Views/MainViewController.swift index 4f4bda6..0c47b5b 100644 --- a/ScreenPresenter/Views/MainViewController.swift +++ b/ScreenPresenter/Views/MainViewController.swift @@ -756,6 +756,11 @@ final class MainViewController: NSViewController { previewContainerView.setMarkdownEditorPosition(position) } + /// 更新录制中标识 + func setRecordingIndicatorVisible(_ visible: Bool, elapsedSeconds: Int) { + previewContainerView.setRecordingIndicatorVisible(visible, elapsedSeconds: elapsedSeconds) + } + /// 新建 Markdown 文件(清空当前内容) func newMarkdownFile() { previewContainerView.newMarkdownFile() diff --git a/ScreenPresenterTests/RecordingFileNamingTests.swift b/ScreenPresenterTests/RecordingFileNamingTests.swift new file mode 100644 index 0000000..c57190b --- /dev/null +++ b/ScreenPresenterTests/RecordingFileNamingTests.swift @@ -0,0 +1,41 @@ +// +// RecordingFileNamingTests.swift +// ScreenPresenterTests +// +// Created by Sun on 2026/06/30. +// +// 录制文件命名单元测试 +// 验证截图目录和文件名稳定、可读且适合文件系统 +// + +import Foundation +import XCTest +@testable import ScreenPresenter + +final class RecordingFileNamingTests: XCTestCase { + func testDeviceDirectoryNameReplacesInvalidFileNameCharacters() { + let result = RecordingFileNaming.deviceDirectoryName( + platformName: "iOS", + deviceName: #"Tank/iPhone:15*Pro?"# + ) + + XCTAssertEqual(result, "iOS-Tank-iPhone-15-Pro") + } + + func testSnapshotFileNameIncludesElapsedSecondAndWallClockSecond() { + var components = DateComponents() + components.calendar = Calendar.current + components.year = 2026 + components.month = 6 + components.day = 30 + components.hour = 10 + components.minute = 18 + components.second = 43 + + let date = try XCTUnwrap(components.date) + + let result = RecordingFileNaming.snapshotFileName(elapsedSecond: 7, date: date) + + XCTAssertEqual(result, "000007_2026-06-30_10-18-43.jpg") + } +}