diff --git a/ScreenPresenter/AppDelegate.swift b/ScreenPresenter/AppDelegate.swift index 81910ee..d6332d7 100644 --- a/ScreenPresenter/AppDelegate.swift +++ b/ScreenPresenter/AppDelegate.swift @@ -33,6 +33,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { private var recordingOutputToolbarItem: NSToolbarItem? private let recordingLibrary = RecordingLibrary() private var recordingHistoryWindowController: NSWindowController? + /// 已提示过的录制失败消息,避免状态发布器重复触发时反复弹 Toast。 + private var lastRecordingFailureMessage: String? private var markdownToggleToolbarItem: NSToolbarItem? private var isRefreshing: Bool = false private var cancellables = Set() @@ -59,6 +61,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { private var isCaptureQuitConfirmationPresented = false private var isUnsavedNewMarkdownQuitFlowInProgress = false private var quitConfirmationShortcutMonitor: Any? + /// 当前投屏退出确认的结果回调,供快捷键监视器在弹窗展示时直接结算。 + private var pendingCaptureQuitCompletion: ((Bool) -> Void)? // MARK: - 应用生命周期 @@ -295,7 +299,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { // 没有“已落盘但未保存”的文档时,按投屏状态直接决定退出行为 guard mainViewController.hasUnsavedFileBackedMarkdownDocuments() else { if hasActiveCaptureSession { - presentCaptureQuitConfirmation() + presentCaptureQuitConfirmation { [weak self] confirmed in + self?.replyCaptureQuitToTermination(confirmed) + } return .terminateLater } return .terminateNow @@ -338,13 +344,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { private func continueTerminationAfterDocumentSave() { if hasActiveCaptureSession { - presentCaptureQuitConfirmation() + presentCaptureQuitConfirmation { [weak self] confirmed in + self?.replyCaptureQuitToTermination(confirmed) + } } else { isTerminationCloseApproved = true NSApp.reply(toApplicationShouldTerminate: true) } } + /// 将投屏退出确认结果结算到 AppKit 终止流程:记录审批结果并回复。 + private func replyCaptureQuitToTermination(_ confirmed: Bool) { + isTerminationCloseApproved = confirmed + NSApp.reply(toApplicationShouldTerminate: confirmed) + } + private enum UnsavedMarkdownQuitDecision { case save case discard @@ -384,8 +398,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { } } - private func presentCaptureQuitConfirmation() { - guard !isCaptureQuitConfirmationPresented else { return } + /// 展示"投屏中退出"二次确认。必须在主窗口仍可见时调用,否则 sheet 会贴到已关闭的窗口上。 + /// - Parameter completion: 用户选择结果,true 表示确认退出。 + private func presentCaptureQuitConfirmation(completion: @escaping (Bool) -> Void) { + guard !isCaptureQuitConfirmationPresented else { + completion(false) + return + } isCaptureQuitConfirmationPresented = true let alert = NSAlert() @@ -406,11 +425,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { guard let self else { return } self.stopQuitConfirmationShortcutMonitor() self.isCaptureQuitConfirmationPresented = false - self.isTerminationCloseApproved = confirmed - NSApp.reply(toApplicationShouldTerminate: confirmed) + self.pendingCaptureQuitCompletion = nil + completion(confirmed) } + pendingCaptureQuitCompletion = complete - if let mainWindow { + if let mainWindow, mainWindow.isVisible { alert.beginSheetModal(for: mainWindow) { response in complete(response == .alertFirstButtonReturn) } @@ -431,10 +451,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { if let mainWindow = self.mainWindow, let sheet = mainWindow.attachedSheet { mainWindow.endSheet(sheet, returnCode: .alertFirstButtonReturn) } else { - self.stopQuitConfirmationShortcutMonitor() - self.isCaptureQuitConfirmationPresented = false - self.isTerminationCloseApproved = true - NSApp.reply(toApplicationShouldTerminate: true) + // 无 sheet(极少数窗口不可见的兜底路径):直接以"确认"结算当前退出流程。 + self.pendingCaptureQuitCompletion?(true) } return nil } @@ -1222,9 +1240,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { .receive(on: DispatchQueue.main) .sink { [weak self] in self?.updateRecordingUI() + self?.surfaceRecordingFailureIfNeeded() } .store(in: &cancellables) } + + /// 被动停录(磁盘不足/写失败等)时经状态发布器弹出错误提示,仅在新消息出现时提示一次。 + @MainActor + private func surfaceRecordingFailureIfNeeded() { + guard case let .failed(message) = AppState.shared.recordingService.state else { + lastRecordingFailureMessage = nil + return + } + guard message != lastRecordingFailureMessage else { return } + lastRecordingFailureMessage = message + ToastView.error(message, in: mainWindow) + } } // MARK: - 窗口代理 @@ -1256,8 +1287,21 @@ extension AppDelegate: NSWindowDelegate { mainViewController.requestCloseMarkdownIfNeeded { [weak self, weak sender] shouldClose in guard let self, let sender else { return } guard shouldClose else { return } - self.isClosingWindowAfterMarkdownConfirmation = true - sender.performClose(nil) + + // Markdown 已确认。投屏进行中时,在窗口仍可见时先弹退出确认, + // 避免窗口关闭后确认框贴到已消失的窗口上并循环弹出(见 issue #19)。 + if self.hasActiveCaptureSession { + self.presentCaptureQuitConfirmation { [weak self] confirmed in + guard let self, confirmed else { return } // 取消:窗口保持打开 + // 确认:投屏中关闭窗口等同退出应用。窗口仍开着时直接终止, + // 保证 applicationShouldTerminate 读到的审批状态未被 windowWillClose 重置。 + self.isTerminationCloseApproved = true + NSApp.terminate(nil) + } + } else { + self.isClosingWindowAfterMarkdownConfirmation = true + sender.performClose(nil) + } } return false } @@ -1511,8 +1555,8 @@ extension AppDelegate { let directory = recordingService.stopRecording() AppState.shared.iosDeviceSource?.stopAudioCaptureForCurrentSession() updateRecordingUI() - if directory != nil { - ToastView.success(L10n.recording.saved, in: mainWindow) + if let directory { + presentRecordingSavedToast(directory: directory) } return } @@ -1522,17 +1566,50 @@ extension AppDelegate { try await recordingService.startRecording() await MainActor.run { updateRecordingUI() - ToastView.success(L10n.recording.started, in: mainWindow) + presentRecordingStartToast(audioStatus: recordingService.audioStatus) } } catch { + // 失败提示统一由 setupRecordingObservation 监听 .failed 状态弹出,避免重复。 await MainActor.run { updateRecordingUI() - ToastView.error(error.localizedDescription, in: mainWindow) } } } } + /// 停录成功提示:能构建出会话则带「回放」动作按钮,点击直接打开回放;否则退回普通提示。 + /// 不自动弹窗,避免投屏时打断画面或暴露给观众。 + @MainActor + private func presentRecordingSavedToast(directory: URL) { + guard let session = recordingLibrary.makeSession(from: directory) else { + ToastView.success(L10n.recording.saved, in: mainWindow) + return + } + ToastView.success( + L10n.recording.saved, + actionTitle: L10n.recording.replay, + in: mainWindow + ) { [weak self] in + self?.mainViewController?.showRecordingReplay(session: session) + } + } + + /// 按音频轨道状态弹出录制开始提示:有音频用 success,缺音频按成因用 warning 分别告知。 + @MainActor + private func presentRecordingStartToast(audioStatus: RecordingAudioStatus) { + switch audioStatus { + case .enabled, .disabled: + // 正常录音,或用户主动关闭麦克风:均属预期,用 success。 + ToastView.success(L10n.recording.started, in: mainWindow) + case .noDevice: + ToastView.warning(L10n.recording.startedNoMicrophoneDevice, in: mainWindow) + case .permissionDenied: + ToastView.warning(L10n.recording.startedMicrophoneDenied, in: mainWindow) + case .unavailable: + ToastView.warning(L10n.recording.startedMicrophoneUnavailable, in: mainWindow) + } + } + @objc private func showRecordingHistory(_ sender: Any?) { let historyView = RecordingHistoryPopoverView( frame: NSRect(x: 0, y: 0, width: 560, height: 400) @@ -1566,6 +1643,10 @@ extension AppDelegate { self?.mainViewController?.showRecordingReplay(session: session) } + historyView.onDeleteSession = { [weak self, weak historyView] session in + self?.confirmAndDeleteRecording(session, in: historyView) + } + dismissRecordingHistoryWindow(recordingHistoryWindowController?.window) recordingHistoryWindowController = windowController @@ -1575,15 +1656,56 @@ extension AppDelegate { window.orderFrontRegardless() NSApp.activate(ignoringOtherApps: true) + reloadRecordingHistory(into: historyView) + } + + /// 扫描并刷新历史列表,同时更新总占用摘要。 + private func reloadRecordingHistory(into historyView: RecordingHistoryPopoverView) { do { let sessions = try recordingLibrary.scanSessions() - historyView.configure(sessions: sessions) + historyView.configure(sessions: sessions, summaryText: historySummaryText(for: sessions)) } catch { AppLogger.capture.error("读取录制历史失败: \(error.localizedDescription)") historyView.configure(sessions: []) } } + private func historySummaryText(for sessions: [RecordingSession]) -> String { + guard !sessions.isEmpty else { return "" } + let bytes = recordingLibrary.totalSize(of: sessions) + let sizeText = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + return L10n.recording.historySummary(sessions.count, sizeText) + } + + /// 弹确认框,确认后将会话移入废纸篓并刷新列表。 + private func confirmAndDeleteRecording( + _ session: RecordingSession, + in historyView: RecordingHistoryPopoverView? + ) { + guard let historyView, let window = recordingHistoryWindowController?.window else { return } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = L10n.recording.deleteConfirmTitle + alert.informativeText = L10n.recording.deleteConfirmMessage(session.directory.lastPathComponent) + alert.addButton(withTitle: L10n.recording.deleteConfirmButton) + alert.addButton(withTitle: L10n.recording.cancel) + + alert.beginSheetModal(for: window) { [weak self, weak historyView] response in + guard response == .alertFirstButtonReturn, let self, let historyView else { return } + do { + try self.recordingLibrary.deleteSession(session) + self.reloadRecordingHistory(into: historyView) + } catch { + AppLogger.capture.error("删除录制失败: \(error.localizedDescription)") + // beginSheetModal 的 completion 在主线程回调,断言主线程隔离以调用 @MainActor 的 Toast。 + MainActor.assumeIsolated { + ToastView.error(L10n.recording.deleteFailed, in: window) + } + } + } + } + private func positionRecordingHistoryWindow(_ window: NSWindow) { guard let mainWindow else { window.center() diff --git a/ScreenPresenter/Core/Preferences/UserPreferences.swift b/ScreenPresenter/Core/Preferences/UserPreferences.swift index 922ec27..2211f1d 100644 --- a/ScreenPresenter/Core/Preferences/UserPreferences.swift +++ b/ScreenPresenter/Core/Preferences/UserPreferences.swift @@ -27,6 +27,41 @@ enum ScrcpyCodecType: String, CaseIterable { } } +// MARK: - 录制画质档位 + +/// 投屏录制的截图画质档位。合成分辨率上限与 JPEG 质量两个内部参数。 +enum RecordingImageQuality: String, CaseIterable { + case low + case medium + case high + + /// 截图长边像素上限。 + var maxLongSide: CGFloat { + switch self { + case .low: 960 + case .medium: 1280 + case .high: 1920 + } + } + + /// JPEG 压缩质量。 + var jpegQuality: CGFloat { + switch self { + case .low: 0.5 + case .medium: 0.65 + case .high: 0.8 + } + } + + var displayName: String { + switch self { + case .low: L10n.prefs.recording.qualityLow + case .medium: L10n.prefs.recording.qualityMedium + case .high: L10n.prefs.recording.qualityHigh + } + } +} + // MARK: - 用户偏好设置模型 /// 用户偏好设置 @@ -63,6 +98,11 @@ final class UserPreferences { static let androidAudioEnabled = "androidAudioEnabled" static let androidAudioVolume = "androidAudioVolume" static let androidAudioCodec = "androidAudioCodec" + // 投屏录制 + static let recordingSaveDirectory = "recordingSaveDirectory" + static let recordingSnapshotInterval = "recordingSnapshotInterval" + static let recordingImageQuality = "recordingImageQuality" + static let recordingMicrophoneEnabled = "recordingMicrophoneEnabled" // Markdown 编辑器 static let markdownEditorVisible = "markdownEditorVisible" static let markdownEditorPosition = "markdownEditorPosition" @@ -381,6 +421,62 @@ final class UserPreferences { } } + // MARK: - 投屏录制设置 + + /// 录制文件保存的基准目录。实际会话写入 `<基准>/ScreenPresenter Recordings/` 之下。 + /// 默认 ~/Movies;返回 nil 表示使用默认位置。 + var recordingSaveDirectory: URL? { + get { + guard let path = defaults.string(forKey: Keys.recordingSaveDirectory), !path.isEmpty else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: true) + } + set { defaults.set(newValue?.path, forKey: Keys.recordingSaveDirectory) } + } + + /// 截图间隔(秒)。每隔 N 秒截一帧,决定回放粒度与磁盘占用,默认 1。 + /// 受整秒时间轴模型约束,最小为 1s(即上限 1fps)。 + var recordingSnapshotInterval: Int { + get { + let value = defaults.integer(forKey: Keys.recordingSnapshotInterval) + return value > 0 ? value : 1 + } + set { defaults.set(max(1, newValue), forKey: Keys.recordingSnapshotInterval) } + } + + /// 截图画质档位(低/中/高)。默认中。 + var recordingImageQuality: RecordingImageQuality { + get { + guard + let rawValue = defaults.string(forKey: Keys.recordingImageQuality), + let quality = RecordingImageQuality(rawValue: rawValue) else { + return .medium + } + return quality + } + set { defaults.set(newValue.rawValue, forKey: Keys.recordingImageQuality) } + } + + /// 录制时是否采集麦克风音频。关闭则纯画面录制,不请求权限。默认开。 + var recordingMicrophoneEnabled: Bool { + get { + if defaults.object(forKey: Keys.recordingMicrophoneEnabled) == nil { + return true + } + return defaults.bool(forKey: Keys.recordingMicrophoneEnabled) + } + set { defaults.set(newValue, forKey: Keys.recordingMicrophoneEnabled) } + } + + /// 录制会话的根目录:`<保存位置或默认 Movies>/ScreenPresenter Recordings/`。 + /// 录制写入与历史扫描都以此为准,保证一致。 + func recordingsRootDirectory(fileManager: FileManager = .default) -> URL { + let base = recordingSaveDirectory ?? fileManager.urls(for: .moviesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true).appendingPathComponent("Movies", isDirectory: true) + return base.appendingPathComponent("ScreenPresenter Recordings", isDirectory: true) + } + /// 本次应用启动会话中,指定平台的音频捕获是否开启 /// 说明:该值在应用启动时固定,偏好设置的运行时修改需要重启应用后才生效。 func isAudioCaptureEnabledForCurrentSession(platform: DevicePlatform) -> Bool { @@ -416,6 +512,9 @@ final class UserPreferences { Keys.iosAudioVolume: 1.0, Keys.androidAudioEnabled: false, Keys.androidAudioVolume: 1.0, + Keys.recordingSnapshotInterval: 1, + Keys.recordingImageQuality: RecordingImageQuality.medium.rawValue, + Keys.recordingMicrophoneEnabled: true, ]) } diff --git a/ScreenPresenter/Core/Recording/RecordingLibrary.swift b/ScreenPresenter/Core/Recording/RecordingLibrary.swift index 37e11eb..addce38 100644 --- a/ScreenPresenter/Core/Recording/RecordingLibrary.swift +++ b/ScreenPresenter/Core/Recording/RecordingLibrary.swift @@ -66,7 +66,8 @@ struct RecordingDeviceTrack: Hashable { struct RecordingSession: Hashable { let directory: URL - let audioURL: URL + /// 音频文件地址。纯截图录制(无麦克风/拒权限)时为 nil。 + let audioURL: URL? let startedAt: Date let duration: TimeInterval let deviceTracks: [RecordingDeviceTrack] @@ -77,10 +78,16 @@ struct RecordingSession: Hashable { struct RecordingLibrary { typealias AudioDurationProvider = (URL) throws -> TimeInterval - private let rootDirectory: URL + /// 注入的根目录(测试用);为 nil 时动态读取用户偏好,使保存位置改动即时生效。 + private let injectedRootDirectory: URL? private let fileManager: FileManager private let audioDurationProvider: AudioDurationProvider + /// 生效的录制根目录:优先注入值,否则取偏好中的保存位置。 + private var rootDirectory: URL { + injectedRootDirectory ?? UserPreferences.shared.recordingsRootDirectory(fileManager: fileManager) + } + private static let sessionDirectoryFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -94,7 +101,7 @@ struct RecordingLibrary { audioDurationProvider: @escaping AudioDurationProvider = RecordingLibrary.defaultAudioDuration ) { self.fileManager = fileManager - self.rootDirectory = rootDirectory ?? RecordingLibrary.defaultRootDirectory(fileManager: fileManager) + self.injectedRootDirectory = rootDirectory self.audioDurationProvider = audioDurationProvider } @@ -109,33 +116,80 @@ struct RecordingLibrary { options: [.skipsHiddenFiles] ) - let sessions = sessionDirectories.compactMap { directory -> RecordingSession? in - guard fileManager.directoryExists(at: directory) else { - return nil - } + let sessions = sessionDirectories.compactMap(makeSession(from:)) - let audioURL = directory.appendingPathComponent("audio.m4a", isDirectory: false) - guard fileManager.fileExists(atPath: audioURL.path) else { - return nil - } + return sessions.sorted { lhs, rhs in + lhs.startedAt > rhs.startedAt + } + } - do { - return RecordingSession( - directory: directory, - audioURL: audioURL, - startedAt: startedAt(for: directory), - duration: try audioDurationProvider(audioURL), - deviceTracks: scanDeviceTracks(in: directory) - ) - } catch { - AppLogger.capture.warning("跳过损坏的录制记录: \(directory.path), \(error.localizedDescription)") - return nil - } + /// 从单个会话目录构建 `RecordingSession`。无音频且无任何截图则视为无效目录返回 nil。 + /// 供历史扫描与停录后即时构建会话共用。 + func makeSession(from directory: URL) -> RecordingSession? { + guard fileManager.directoryExists(at: directory) else { + return nil } - return sessions.sorted { lhs, rhs in - lhs.startedAt > rhs.startedAt + let audioFileURL = directory.appendingPathComponent("audio.m4a", isDirectory: false) + let hasAudio = fileManager.fileExists(atPath: audioFileURL.path) + let deviceTracks = scanDeviceTracks(in: directory) + let hasSnapshots = deviceTracks.contains { !$0.snapshots.isEmpty } + + // 无音频且无任何截图才视为无效目录。纯截图录制仍应出现在历史中。 + guard hasAudio || hasSnapshots else { + return nil } + + let audioDuration = hasAudio ? (try? audioDurationProvider(audioFileURL)) ?? 0 : 0 + let duration = audioDuration > 0 ? audioDuration : snapshotDuration(of: deviceTracks) + + return RecordingSession( + directory: directory, + audioURL: hasAudio ? audioFileURL : nil, + startedAt: startedAt(for: directory), + duration: duration, + deviceTracks: deviceTracks + ) + } + + /// 将指定会话移入废纸篓(可恢复,比直接删除更安全)。仅允许删除录制根目录下的会话。 + func deleteSession(_ session: RecordingSession) throws { + let directory = session.directory.standardizedFileURL + let root = rootDirectory.standardizedFileURL + guard directory.deletingLastPathComponent().path == root.path else { + AppLogger.capture.warning("拒绝删除非录制根目录下的目录: \(directory.path)") + throw CocoaError(.fileWriteNoPermission) + } + try fileManager.trashItem(at: directory, resultingItemURL: nil) + AppLogger.capture.info("已将录制会话移入废纸篓: \(directory.path)") + } + + /// 统计给定会话占用的磁盘字节总数(用于历史窗口展示总占用)。 + func totalSize(of sessions: [RecordingSession]) -> Int64 { + sessions.reduce(0) { $0 + directorySize(at: $1.directory) } + } + + private func directorySize(at directory: URL) -> Int64 { + guard let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey] + ) else { + return 0 + } + var total: Int64 = 0 + for case let fileURL as URL in enumerator { + let values = try? fileURL.resourceValues( + forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey] + ) + total += Int64(values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0) + } + return total + } + + /// 无音频时根据截图的最大相对秒估算录制时长。 + private func snapshotDuration(of tracks: [RecordingDeviceTrack]) -> TimeInterval { + let maxSecond = tracks.compactMap { $0.snapshots.last?.elapsedSecond }.max() ?? 0 + return TimeInterval(maxSecond + 1) } private func scanDeviceTracks(in sessionDirectory: URL) -> [RecordingDeviceTrack] { @@ -230,13 +284,6 @@ struct RecordingLibrary { return values?.creationDate ?? values?.contentModificationDate ?? .distantPast } - private static func defaultRootDirectory(fileManager: FileManager) -> URL { - let moviesDirectory = fileManager.urls(for: .moviesDirectory, in: .userDomainMask).first - ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Movies", isDirectory: true) - - return moviesDirectory.appendingPathComponent("ScreenPresenter Recordings", isDirectory: true) - } - private static func defaultAudioDuration(audioURL: URL) throws -> TimeInterval { let audioFile = try AVAudioFile(forReading: audioURL) let sampleRate = audioFile.fileFormat.sampleRate diff --git a/ScreenPresenter/Core/Recording/RecordingService.swift b/ScreenPresenter/Core/Recording/RecordingService.swift index 7dc27ec..91f0a31 100644 --- a/ScreenPresenter/Core/Recording/RecordingService.swift +++ b/ScreenPresenter/Core/Recording/RecordingService.swift @@ -34,6 +34,7 @@ enum RecordingServiceError: LocalizedError { case microphonePermissionDenied case recorderStartFailed case outputDirectoryUnavailable + case insufficientDiskSpace var errorDescription: String? { switch self { @@ -43,10 +44,28 @@ enum RecordingServiceError: LocalizedError { L10n.recording.startFailed case .outputDirectoryUnavailable: L10n.recording.outputDirectoryUnavailable + case .insufficientDiskSpace: + L10n.recording.insufficientDiskSpace } } } +// MARK: - 音频轨道状态 + +/// 录制时音频轨道的最终状态。用于向用户准确说明"为什么没有声音"。 +enum RecordingAudioStatus: Equatable { + /// 音频正常录制中。 + case enabled + /// 用户在偏好设置中主动关闭了麦克风录制。 + case disabled + /// 设备没有麦克风硬件。 + case noDevice + /// 存在麦克风,但未授予权限(被拒绝 / 受限 / 用户拒绝)。 + case permissionDenied + /// 存在麦克风且有权限,但录音器启动失败(被占用、硬件异常等)。 + case unavailable +} + // MARK: - 录制服务 @MainActor @@ -71,11 +90,16 @@ final class RecordingService: NSObject { didSet { stateChangedPublisher.send() } } + /// 本次录制音频轨道的最终状态。默认无音频;startRecording 时按实际情况判定。 + private(set) var audioStatus: RecordingAudioStatus = .noDevice + + /// 本次录制是否启用了音频轨道。麦克风不可用或权限被拒时为 false,仍进行纯截图录制。 + var isAudioEnabled: Bool { audioStatus == .enabled } + // MARK: - 私有属性 private let frameProvider: FrameProvider private let fileManager: FileManager - private let imageContext = CIContext(options: [.useSoftwareRenderer: false]) private var audioRecorder: AVAudioRecorder? private var snapshotTimer: Timer? @@ -84,8 +108,22 @@ final class RecordingService: NSObject { private var outputDirectory: URL? private var deviceDirectories: [String: URL] = [:] - private let maxSnapshotLongSide: CGFloat = 1280 - private let jpegQuality: CGFloat = 0.65 + /// 录制启动前要求的最小可用磁盘空间。 + private let minFreeBytesToStart: Int64 = 1_024 * 1_024 * 1_024 + /// 录制过程中的安全下限,低于此值主动停录。 + private let minFreeBytesDuringRecording: Int64 = 200 * 1_024 * 1_024 + /// 连续写入失败达到此阈值即主动停录。 + private let maxConsecutiveWriteFailures = 3 + + private var consecutiveWriteFailures = 0 + private var snapshotTickCount = 0 + + /// 截图编码器。按当前画质偏好在每次录制开始时重建。 + private var snapshotEncoder = SnapshotEncoder( + maxLongSide: RecordingImageQuality.medium.maxLongSide, + quality: RecordingImageQuality.medium.jpegQuality, + maxPendingFrames: 12 + ) // MARK: - 初始化 @@ -108,27 +146,38 @@ final class RecordingService: NSObject { lastOutputDirectory = nil elapsedSeconds = 0 + audioStatus = .noDevice + consecutiveWriteFailures = 0 + snapshotTickCount = 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 + // 录制前预检磁盘空间,避免录到一半写满。 + if availableCapacity(at: directory) < minFreeBytesToStart { + throw RecordingServiceError.insufficientDiskSpace } + // 按当前画质偏好重建截图编码器。 + let quality = UserPreferences.shared.recordingImageQuality + snapshotEncoder = SnapshotEncoder( + maxLongSide: quality.maxLongSide, + quality: quality.jpegQuality, + maxPendingFrames: 12 + ) + + // 音频降级为可选轨道:无麦克风 / 权限被拒 / 麦克风不可用都不中断录制,仅记录画面截图, + // 并把具体成因记入 audioStatus 供 UI 准确提示。 + audioStatus = await configureAudioTrack(in: directory) + startedAt = now outputDirectory = directory - audioRecorder = recorder state = .recording(startedAt: now, outputDirectory: directory) startTimers() - AppLogger.capture.info("录制已开始: \(directory.path)") + AppLogger.capture.info("录制已开始: \(directory.path),音频状态: \(String(describing: audioStatus))") } catch { cleanupAfterFailedStart() state = .failed(error.localizedDescription) @@ -162,19 +211,67 @@ final class RecordingService: NSObject { // MARK: - 权限和录音 - private func ensureMicrophoneAccess() async throws { + /// 判定音频轨道状态并在可用时启动录音器。区分三种"没有音频"的成因: + /// 无麦克风硬件 / 权限被拒 / 麦克风存在但不可用。 + private func configureAudioTrack(in directory: URL) async -> RecordingAudioStatus { + // 0. 用户主动关闭麦克风录制时,直接纯画面录制,不查硬件/权限。 + guard UserPreferences.shared.recordingMicrophoneEnabled else { + AppLogger.capture.info("用户已关闭麦克风录制,仅录制画面截图") + return .disabled + } + + // 1. 先看是否有麦克风硬件。没有设备时权限查询无意义。 + guard hasMicrophoneDevice() else { + AppLogger.capture.info("未检测到麦克风设备,仅录制画面截图") + return .noDevice + } + + // 2. 有设备再确认权限。 + guard await ensureMicrophoneAccess() else { + AppLogger.capture.info("未获得麦克风权限,仅录制画面截图") + return .permissionDenied + } + + // 3. 有设备有权限,尝试启动录音器;失败视为麦克风不可用(被占用 / 硬件异常等)。 + let audioURL = directory.appendingPathComponent("audio.m4a") + guard let recorder = try? makeAudioRecorder(outputURL: audioURL), recorder.record() else { + AppLogger.capture.warning("麦克风可用但录音器启动失败,仅录制画面截图") + return .unavailable + } + + audioRecorder = recorder + return .enabled + } + + /// 是否存在可用的麦克风硬件。用 discovery session 枚举内建/外接麦克风。 + /// deviceType 在 macOS 14 起改名(.builtInMicrophone→.microphone、.externalUnknown→.external), + /// 旧值在 14+ 已废弃,故按系统版本选择对应类型。 + private func hasMicrophoneDevice() -> Bool { + let deviceTypes: [AVCaptureDevice.DeviceType] + if #available(macOS 14.0, *) { + deviceTypes = [.microphone, .external] + } else { + deviceTypes = [.builtInMicrophone, .externalUnknown] + } + let session = AVCaptureDevice.DiscoverySession( + deviceTypes: deviceTypes, + mediaType: .audio, + position: .unspecified + ) + return !session.devices.isEmpty + } + + /// 检查麦克风访问权限。返回是否可用于录音,不再以抛错方式阻断整场录制。 + private func ensureMicrophoneAccess() async -> Bool { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: - return + return true case .notDetermined: - let granted = await requestMicrophoneAccess() - guard granted else { - throw RecordingServiceError.microphonePermissionDenied - } + return await requestMicrophoneAccess() case .denied, .restricted: - throw RecordingServiceError.microphonePermissionDenied + return false @unknown default: - throw RecordingServiceError.microphonePermissionDenied + return false } } @@ -204,11 +301,8 @@ final class RecordingService: NSObject { // 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 rootDirectory = UserPreferences.shared.recordingsRootDirectory(fileManager: fileManager) let sessionDirectory = rootDirectory.appendingPathComponent( RecordingFileNaming.sessionDirectoryName(date: startedAt), isDirectory: true @@ -252,7 +346,9 @@ final class RecordingService: NSObject { RunLoop.main.add(elapsedTimer, forMode: .common) self.elapsedTimer = elapsedTimer - let snapshotTimer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + // 截图间隔取自偏好(秒)。整秒时间轴模型下最小 1s。 + let snapshotInterval = TimeInterval(max(1, UserPreferences.shared.recordingSnapshotInterval)) + let snapshotTimer = Timer(timeInterval: snapshotInterval, repeats: true) { [weak self] _ in guard let self else { return } Task { @MainActor in self.captureCurrentDeviceSnapshots() @@ -275,6 +371,14 @@ final class RecordingService: NSObject { private func captureCurrentDeviceSnapshots() { guard state.isRecording, let startedAt else { return } + // 周期性空间检查(约每 10 秒一次),低于安全线主动停录。 + snapshotTickCount += 1 + if snapshotTickCount % 10 == 0, let outputDirectory, + availableCapacity(at: outputDirectory) < minFreeBytesDuringRecording { + abortRecording(with: .insufficientDiskSpace) + return + } + let now = Date() let elapsedSecond = max(1, Int(now.timeIntervalSince(startedAt))) elapsedSeconds = elapsedSecond @@ -284,59 +388,77 @@ final class RecordingService: NSObject { 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) + // 深拷贝在主线程完成,编码/写盘在后台执行,结果回主线程记账。 + snapshotEncoder.submit(pixelBuffer: snapshot.pixelBuffer, to: outputURL) { [weak self] ok in + self?.handleSnapshotWriteResult(success: ok) + } } catch { AppLogger.capture.warning("保存录制截图失败: \(error.localizedDescription)") + handleSnapshotWriteResult(success: false) } } } - 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 + /// 汇总一帧写盘结果:连续失败达阈值则主动停录。仅在录制中生效。 + private func handleSnapshotWriteResult(success: Bool) { + guard state.isRecording else { return } + if success { + consecutiveWriteFailures = 0 + } else { + consecutiveWriteFailures += 1 + if consecutiveWriteFailures >= maxConsecutiveWriteFailures { + abortRecording(with: .insufficientDiskSpace) + } } + } - let options: [CFString: Any] = [ - kCGImageDestinationLossyCompressionQuality: jpegQuality, - ] - CGImageDestinationAddImage(destination, cgImage, options as CFDictionary) - - if !CGImageDestinationFinalize(destination) { - throw RecordingServiceError.outputDirectoryUnavailable - } + /// 因异常(磁盘不足/写失败)被动停录:收尾资源并置为 failed,供 UI 感知。 + private func abortRecording(with error: RecordingServiceError) { + AppLogger.capture.error("录制被动停止: \(error.localizedDescription)") + _ = stopRecording() + state = .failed(error.localizedDescription) } - private func snapshotScale(for size: CGSize) -> CGFloat { - let longSide = max(size.width, size.height) - guard longSide > maxSnapshotLongSide else { return 1 } - return maxSnapshotLongSide / longSide + /// 返回目录所在卷的可用空间(重要用途口径),失败时返回 Int64.max 不误停。 + private func availableCapacity(at url: URL) -> Int64 { + let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + return values?.volumeAvailableCapacityForImportantUsage ?? .max } private func cleanupAfterFailedStart() { stopTimers() stopAudioRecorder() + // 删除本次启动已创建、但未产出有效内容的会话目录,避免残留空壳。 + if let directory = outputDirectory { + removeDirectoryIfEmptyOrResidual(directory) + } + startedAt = nil outputDirectory = nil deviceDirectories.removeAll() elapsedSeconds = 0 } + /// 仅当目录为空、或只含启动失败残片(如未写入内容的 audio.m4a)时删除,避免误删有效数据。 + private func removeDirectoryIfEmptyOrResidual(_ directory: URL) { + guard fileManager.fileExists(atPath: directory.path) else { return } + + let contents = (try? fileManager.contentsOfDirectory(atPath: directory.path)) ?? [] + let residualOnly = contents.allSatisfy { $0 == "audio.m4a" || $0 == ".DS_Store" } + guard residualOnly else { + AppLogger.capture.warning("启动失败目录含额外内容,保留不删除: \(directory.path)") + return + } + + do { + try fileManager.removeItem(at: directory) + AppLogger.capture.info("已清理启动失败的空录制目录: \(directory.path)") + } catch { + AppLogger.capture.warning("清理启动失败目录失败: \(error.localizedDescription)") + } + } + private func stopTimers() { snapshotTimer?.invalidate() elapsedTimer?.invalidate() @@ -364,3 +486,126 @@ extension RecordingService: AVAudioRecorderDelegate { } } } + +// MARK: - 截图编码器 + +/// 将截图的缩放、JPEG 编码与写盘放到后台串行队列执行,避免阻塞主线程。 +/// 提交前在调用线程完成 pixelBuffer 深拷贝,规避上游复用缓冲导致的读到错帧/崩溃。 +final class SnapshotEncoder { + private let context = CIContext(options: [.useSoftwareRenderer: false]) + private let queue = DispatchQueue(label: "com.screenPresenter.recording.encode", qos: .utility) + private let maxLongSide: CGFloat + private let quality: CGFloat + private let maxPendingFrames: Int + + private let lock = NSLock() + private var pendingCount = 0 + + init(maxLongSide: CGFloat, quality: CGFloat, maxPendingFrames: Int) { + self.maxLongSide = maxLongSide + self.quality = quality + self.maxPendingFrames = maxPendingFrames + } + + /// 提交一帧编码。深拷贝在调用线程同步完成;编码与写盘在后台执行。 + /// completion 在主线程回调,参数为是否成功写盘。 + func submit(pixelBuffer: CVPixelBuffer, to outputURL: URL, completion: @escaping (Bool) -> Void) { + // 背压:积压过多时丢最新帧,避免内存无限增长。 + lock.lock() + if pendingCount >= maxPendingFrames { + lock.unlock() + AppLogger.capture.warning("截图编码积压,丢弃当前帧") + return + } + pendingCount += 1 + lock.unlock() + + guard let copy = Self.deepCopy(pixelBuffer) else { + lock.lock(); pendingCount -= 1; lock.unlock() + AppLogger.capture.warning("截图缓冲拷贝失败,跳过当前帧") + DispatchQueue.main.async { completion(false) } + return + } + + queue.async { [weak self] in + guard let self else { return } + let ok = self.encodeAndWrite(pixelBuffer: copy, to: outputURL) + self.lock.lock(); self.pendingCount -= 1; self.lock.unlock() + DispatchQueue.main.async { completion(ok) } + } + } + + private func encodeAndWrite(pixelBuffer: CVPixelBuffer, to outputURL: URL) -> Bool { + let image = CIImage(cvImageBuffer: pixelBuffer) + let longSide = max(image.extent.width, image.extent.height) + let scale = longSide > maxLongSide ? maxLongSide / longSide : 1 + let outputImage = scale < 1 + ? image.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + : image + + guard let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { + return false + } + guard let destination = CGImageDestinationCreateWithURL( + outputURL as CFURL, + UTType.jpeg.identifier as CFString, + 1, + nil + ) else { + return false + } + CGImageDestinationAddImage( + destination, + cgImage, + [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary + ) + return CGImageDestinationFinalize(destination) + } + + /// 深拷贝 CVPixelBuffer(逐平面 memcpy),使后台编码不受上游缓冲复用影响。 + private static func deepCopy(_ source: CVPixelBuffer) -> CVPixelBuffer? { + let width = CVPixelBufferGetWidth(source) + let height = CVPixelBufferGetHeight(source) + let format = CVPixelBufferGetPixelFormatType(source) + let attrs: [CFString: Any] = [ + kCVPixelBufferIOSurfacePropertiesKey: [:], + kCVPixelBufferMetalCompatibilityKey: true, + ] + + var destination: CVPixelBuffer? + guard CVPixelBufferCreate( + kCFAllocatorDefault, width, height, format, attrs as CFDictionary, &destination + ) == kCVReturnSuccess, let destination else { + return nil + } + + CVPixelBufferLockBaseAddress(source, .readOnly) + CVPixelBufferLockBaseAddress(destination, []) + defer { + CVPixelBufferUnlockBaseAddress(destination, []) + CVPixelBufferUnlockBaseAddress(source, .readOnly) + } + + let planeCount = max(1, CVPixelBufferGetPlaneCount(source)) + if CVPixelBufferGetPlaneCount(source) == 0 { + guard let src = CVPixelBufferGetBaseAddress(source), + let dst = CVPixelBufferGetBaseAddress(destination) else { return nil } + let bytes = CVPixelBufferGetBytesPerRow(source) * height + memcpy(dst, src, min(bytes, CVPixelBufferGetBytesPerRow(destination) * height)) + return destination + } + + for plane in 0.. String { "prefs.recording.intervalSeconds".localized(seconds) } + static var imageQuality: String { "prefs.recording.imageQuality".localized } + static var qualityLow: String { "prefs.recording.qualityLow".localized } + static var qualityMedium: String { "prefs.recording.qualityMedium".localized } + static var qualityHigh: String { "prefs.recording.qualityHigh".localized } + static var enableMicrophone: String { "prefs.recording.enableMicrophone".localized } + static var enableMicrophoneNote: String { "prefs.recording.enableMicrophoneNote".localized } + } + enum audioPref { static var enableCapture: String { "prefs.audio.enableCapture".localized } static var volume: String { "prefs.audio.volume".localized } @@ -537,16 +557,31 @@ enum L10n { static var record: String { "recording.record".localized } static var stop: String { "recording.stop".localized } static var started: String { "recording.started".localized } + static var startedNoMicrophoneDevice: String { "recording.startedNoMicrophoneDevice".localized } + static var startedMicrophoneDenied: String { "recording.startedMicrophoneDenied".localized } + static var startedMicrophoneUnavailable: String { "recording.startedMicrophoneUnavailable".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 insufficientDiskSpace: String { "recording.insufficientDiskSpace".localized } static var openOutputDirectory: String { "recording.openOutputDirectory".localized } static var openHistory: String { "recording.openHistory".localized } static var back: String { "recording.back".localized } static var play: String { "recording.play".localized } static var pause: String { "recording.pause".localized } static var revealDirectory: String { "recording.revealDirectory".localized } + static var delete: String { "recording.delete".localized } + static var deleteConfirmTitle: String { "recording.deleteConfirmTitle".localized } + static func deleteConfirmMessage(_ name: String) -> String { + "recording.deleteConfirmMessage".localized(name) + } + static var deleteConfirmButton: String { "recording.deleteConfirmButton".localized } + static var cancel: String { "recording.cancel".localized } + static var deleteFailed: String { "recording.deleteFailed".localized } + static func historySummary(_ count: Int, _ size: String) -> String { + "recording.historySummary".localized(count, size) + } static var replay: String { "recording.replay".localized } static var noHistory: String { "recording.noHistory".localized } static var historyTitle: String { "recording.historyTitle".localized } @@ -596,12 +631,15 @@ enum L10n { static var screenRecordingDesc: String { "permission.screenRecording.desc".localized } static var cameraName: String { "permission.camera.name".localized } static var cameraDesc: String { "permission.camera.desc".localized } + static var microphoneName: String { "permission.microphone.name".localized } + static var microphoneDesc: String { "permission.microphone.desc".localized } static var openSystemPrefs: String { "permission.openSystemPrefs".localized } // 撤销权限 static var revoke: String { "permission.revoke".localized } static var revokeTitle: String { "permission.revokeTitle".localized } static var revokeScreenRecordingHint: String { "permission.revokeScreenRecordingHint".localized } static var revokeCameraHint: String { "permission.revokeCameraHint".localized } + static var revokeMicrophoneHint: String { "permission.revokeMicrophoneHint".localized } static var revokeNote: String { "permission.revokeNote".localized } } diff --git a/ScreenPresenter/Resources/en.lproj/Localizable.strings b/ScreenPresenter/Resources/en.lproj/Localizable.strings index 1fa787c..d6066b0 100644 --- a/ScreenPresenter/Resources/en.lproj/Localizable.strings +++ b/ScreenPresenter/Resources/en.lproj/Localizable.strings @@ -139,6 +139,7 @@ // MARK: - Preferences - Tabs "prefs.tab.general" = "General"; "prefs.tab.capture" = "Capture"; +"prefs.tab.recording" = "Recording"; "prefs.tab.scrcpy" = "Scrcpy"; "prefs.tab.permissions" = "Permissions"; "prefs.tab.toolchain" = "Toolchain"; @@ -149,6 +150,9 @@ "prefs.section.appearance" = "Appearance"; "prefs.section.layout" = "Layout"; "prefs.section.frameRate" = "Frame Rate"; +"prefs.section.recordingOutput" = "Save Location"; +"prefs.section.recordingSnapshot" = "Screen Snapshots"; +"prefs.section.recordingAudio" = "Audio"; "prefs.section.audio" = "Audio"; "prefs.section.iosAudio" = "iOS Audio"; "prefs.section.androidAudio" = "Android Audio"; @@ -199,6 +203,19 @@ // MARK: - Preferences - Capture "prefs.capture.frameRate" = "Capture Frame Rate"; "prefs.capture.frameRateNote" = "Higher frame rate increases CPU and GPU load"; +"prefs.recording.saveLocation" = "Save to"; +"prefs.recording.saveLocationNote" = "Recordings are stored in a \"ScreenPresenter Recordings\" subfolder of the chosen directory"; +"prefs.recording.chooseFolder" = "Choose Folder…"; +"prefs.recording.resetToDefault" = "Reset to Default"; +"prefs.recording.snapshotInterval" = "Snapshot Interval"; +"prefs.recording.snapshotIntervalNote" = "Shorter intervals mean smoother replay but more disk usage"; +"prefs.recording.intervalSeconds" = "Every %d s"; +"prefs.recording.imageQuality" = "Snapshot Quality"; +"prefs.recording.qualityLow" = "Low (saves space)"; +"prefs.recording.qualityMedium" = "Medium"; +"prefs.recording.qualityHigh" = "High (sharper)"; +"prefs.recording.enableMicrophone" = "Record Microphone Audio"; +"prefs.recording.enableMicrophoneNote" = "When off, only the screen is recorded and microphone permission is not requested"; // MARK: - Preferences - Scrcpy "prefs.scrcpy.bitrate" = "Bitrate"; @@ -302,11 +319,14 @@ "permission.screenRecording.desc" = "For capturing Android device screen (fallback only)"; "permission.camera.name" = "Camera"; "permission.camera.desc" = "For capturing iOS device screen via USB"; +"permission.microphone.name" = "Microphone"; +"permission.microphone.desc" = "Captures microphone audio while recording"; "permission.openSystemPrefs" = "Open Settings"; "permission.revoke" = "Revoke"; "permission.revokeTitle" = "Revoke Permission"; "permission.revokeScreenRecordingHint" = "To revoke screen recording permission, please uncheck ScreenPresenter in System Settings > Privacy & Security > Screen Recording, then restart the app."; "permission.revokeCameraHint" = "To revoke camera permission, please uncheck ScreenPresenter in System Settings > Privacy & Security > Camera, then restart the app."; +"permission.revokeMicrophoneHint" = "To revoke microphone permission, please uncheck ScreenPresenter in System Settings > Privacy & Security > Microphone, then restart the app."; "permission.revokeNote" = "To revoke a permission, click the \"Revoke\" button and manually uncheck ScreenPresenter in System Settings. Changes will take effect after restarting the app."; // MARK: - Background Color Mode @@ -441,16 +461,27 @@ "recording.record" = "Record"; "recording.stop" = "Stop Recording"; "recording.started" = "Recording started"; +"recording.startedNoMicrophoneDevice" = "Recording started (no microphone detected, screen only)"; +"recording.startedMicrophoneDenied" = "Recording started (microphone permission not granted, screen only)"; +"recording.startedMicrophoneUnavailable" = "Recording started (microphone unavailable, may be in use by another app, screen only)"; "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.insufficientDiskSpace" = "Insufficient disk space. Recording stopped."; "recording.openOutputDirectory" = "Open recording folder"; "recording.openHistory" = "View recording history"; "recording.back" = "Back"; "recording.play" = "Play"; "recording.pause" = "Pause"; "recording.revealDirectory" = "Show Folder"; +"recording.delete" = "Delete"; +"recording.deleteConfirmTitle" = "Delete this recording?"; +"recording.deleteConfirmMessage" = "\"%@\" will be moved to the Trash. You can restore it from there."; +"recording.deleteConfirmButton" = "Move to Trash"; +"recording.cancel" = "Cancel"; +"recording.deleteFailed" = "Failed to delete recording"; +"recording.historySummary" = "%1$d recordings · %2$@"; "recording.replay" = "Replay"; "recording.noHistory" = "No recordings yet"; "recording.historyTitle" = "Recording History"; diff --git a/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings b/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings index 92d5b1e..7b16628 100644 --- a/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings +++ b/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings @@ -139,6 +139,7 @@ // MARK: - Preferences - Tabs "prefs.tab.general" = "通用"; "prefs.tab.capture" = "捕获"; +"prefs.tab.recording" = "投屏录制"; "prefs.tab.scrcpy" = "Scrcpy"; "prefs.tab.permissions" = "权限"; "prefs.tab.toolchain" = "工具链"; @@ -149,6 +150,9 @@ "prefs.section.appearance" = "外观"; "prefs.section.layout" = "布局"; "prefs.section.frameRate" = "帧率"; +"prefs.section.recordingOutput" = "保存位置"; +"prefs.section.recordingSnapshot" = "画面截图"; +"prefs.section.recordingAudio" = "音频"; "prefs.section.audio" = "音频"; "prefs.section.iosAudio" = "iOS 音频"; "prefs.section.androidAudio" = "Android 音频"; @@ -199,6 +203,19 @@ // MARK: - Preferences - Capture "prefs.capture.frameRate" = "捕获帧率"; "prefs.capture.frameRateNote" = "更高的帧率会增加 CPU 和 GPU 负载"; +"prefs.recording.saveLocation" = "保存到"; +"prefs.recording.saveLocationNote" = "录制文件将存放在所选目录的“ScreenPresenter Recordings”子文件夹中"; +"prefs.recording.chooseFolder" = "选择文件夹…"; +"prefs.recording.resetToDefault" = "恢复默认"; +"prefs.recording.snapshotInterval" = "截图间隔"; +"prefs.recording.snapshotIntervalNote" = "间隔越短回放越流畅,占用磁盘也越多"; +"prefs.recording.intervalSeconds" = "每 %d 秒"; +"prefs.recording.imageQuality" = "截图画质"; +"prefs.recording.qualityLow" = "低(更省空间)"; +"prefs.recording.qualityMedium" = "中"; +"prefs.recording.qualityHigh" = "高(更清晰)"; +"prefs.recording.enableMicrophone" = "录制麦克风音频"; +"prefs.recording.enableMicrophoneNote" = "关闭后仅录制画面,不再请求麦克风权限"; // MARK: - Preferences - Scrcpy "prefs.scrcpy.bitrate" = "比特率"; @@ -302,11 +319,14 @@ "permission.screenRecording.desc" = "用于捕获 Android 设备投屏画面(仅备用方案需要)"; "permission.camera.name" = "摄像头"; "permission.camera.desc" = "用于捕获通过 USB 连接的 iOS 设备画面"; +"permission.microphone.name" = "麦克风"; +"permission.microphone.desc" = "录制时用于采集麦克风音频"; "permission.openSystemPrefs" = "打开设置"; "permission.revoke" = "撤销"; "permission.revokeTitle" = "撤销权限"; "permission.revokeScreenRecordingHint" = "要撤销屏幕录制权限,请在「系统设置 > 隐私与安全性 > 屏幕录制」中取消勾选 ScreenPresenter,然后重启应用。"; "permission.revokeCameraHint" = "要撤销摄像头权限,请在「系统设置 > 隐私与安全性 > 摄像头」中取消勾选 ScreenPresenter,然后重启应用。"; +"permission.revokeMicrophoneHint" = "要撤销麦克风权限,请在「系统设置 > 隐私与安全性 > 麦克风」中取消勾选 ScreenPresenter,然后重启应用。"; "permission.revokeNote" = "要撤销权限,请点击「撤销」按钮,然后在系统设置中手动取消勾选 ScreenPresenter。更改将在重启应用后生效。"; // MARK: - Background Color Mode @@ -441,16 +461,27 @@ "recording.record" = "录制"; "recording.stop" = "停止录制"; "recording.started" = "录制已开始"; +"recording.startedNoMicrophoneDevice" = "录制已开始(未检测到麦克风,仅录制画面)"; +"recording.startedMicrophoneDenied" = "录制已开始(未授予麦克风权限,仅录制画面)"; +"recording.startedMicrophoneUnavailable" = "录制已开始(麦克风不可用,可能被其他应用占用,仅录制画面)"; "recording.saved" = "录制已保存"; "recording.startFailed" = "录制开始失败"; "recording.microphonePermissionDenied" = "麦克风权限未开启,请在系统设置中允许 ScreenPresenter 访问麦克风"; "recording.outputDirectoryUnavailable" = "无法创建录制保存目录"; +"recording.insufficientDiskSpace" = "磁盘空间不足,录制已停止"; "recording.openOutputDirectory" = "打开录制目录"; "recording.openHistory" = "查看录制历史"; "recording.back" = "返回"; "recording.play" = "播放"; "recording.pause" = "暂停"; "recording.revealDirectory" = "查看目录"; +"recording.delete" = "删除"; +"recording.deleteConfirmTitle" = "删除这条录制记录?"; +"recording.deleteConfirmMessage" = "将把「%@」移入废纸篓,可从废纸篓恢复。"; +"recording.deleteConfirmButton" = "移入废纸篓"; +"recording.cancel" = "取消"; +"recording.deleteFailed" = "删除录制失败"; +"recording.historySummary" = "共 %1$d 条 · 占用 %2$@"; "recording.replay" = "回放"; "recording.noHistory" = "暂无录制记录"; "recording.historyTitle" = "录制历史"; diff --git a/ScreenPresenter/Views/Components/RecordingHistoryPopoverView.swift b/ScreenPresenter/Views/Components/RecordingHistoryPopoverView.swift index 6bad9ce..7f72eb9 100644 --- a/ScreenPresenter/Views/Components/RecordingHistoryPopoverView.swift +++ b/ScreenPresenter/Views/Components/RecordingHistoryPopoverView.swift @@ -14,12 +14,14 @@ final class RecordingHistoryPopoverView: NSView { var onRevealDirectory: ((RecordingSession) -> Void)? var onReplay: ((RecordingSession) -> Void)? + var onDeleteSession: ((RecordingSession) -> Void)? // MARK: - UI 组件 private let scrollView = NSScrollView() private let tableView = NSTableView() private let emptyLabel = NSTextField(labelWithString: L10n.recording.noHistory) + private let summaryLabel = NSTextField(labelWithString: "") private var sessions: [RecordingSession] = [] // MARK: - 格式化 @@ -55,11 +57,13 @@ final class RecordingHistoryPopoverView: NSView { // MARK: - 公开方法 - func configure(sessions: [RecordingSession]) { + func configure(sessions: [RecordingSession], summaryText: String = "") { self.sessions = sessions emptyLabel.isHidden = !sessions.isEmpty scrollView.isHidden = sessions.isEmpty + summaryLabel.stringValue = summaryText + summaryLabel.isHidden = sessions.isEmpty || summaryText.isEmpty tableView.reloadData() updateTableColumnWidth() } @@ -72,6 +76,21 @@ final class RecordingHistoryPopoverView: NSView { setupScrollView() setupEmptyState() + setupSummaryLabel() + } + + private func setupSummaryLabel() { + summaryLabel.translatesAutoresizingMaskIntoConstraints = false + summaryLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) + summaryLabel.textColor = .tertiaryLabelColor + summaryLabel.alignment = .right + summaryLabel.isHidden = true + addSubview(summaryLabel) + + NSLayoutConstraint.activate([ + summaryLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.contentInset), + summaryLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + ]) } private func setupScrollView() { @@ -99,7 +118,7 @@ final class RecordingHistoryPopoverView: NSView { scrollView.topAnchor.constraint(equalTo: topAnchor, constant: Metrics.contentInset), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.contentInset), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.contentInset), - scrollView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Metrics.contentInset) + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Metrics.contentInset - Metrics.summaryReservedHeight) ]) } @@ -133,6 +152,8 @@ final class RecordingHistoryPopoverView: NSView { row.replayButton.target = self row.replayButton.action = #selector(replayTapped(_:)) row.replayButton.isEnabled = session.duration > 0 + row.deleteButton.target = self + row.deleteButton.action = #selector(deleteTapped(_:)) row.translatesAutoresizingMaskIntoConstraints = false cell.addSubview(row) @@ -177,6 +198,13 @@ final class RecordingHistoryPopoverView: NSView { } onReplay?(session) } + + @objc private func deleteTapped(_ sender: SessionButton) { + guard let session = sender.session else { + return + } + onDeleteSession?(session) + } } // MARK: - 内部类型 @@ -188,6 +216,7 @@ private class SessionButton: NSButton { private final class HistoryRowView: NSView { let revealButton = SessionIconButton(style: .secondary) let replayButton = SessionIconButton(style: .primary) + let deleteButton = SessionIconButton(style: .destructive) private let titleLabel = NSTextField(labelWithString: "") private let subtitleLabel = NSTextField(labelWithString: "") @@ -209,7 +238,7 @@ private final class HistoryRowView: NSView { let midpoint = bounds.width * Metrics.actionAreaStartRatio let actionButtonSize = Metrics.iconButtonSize let actionSpacing = Metrics.iconButtonSpacing - let actionsWidth = actionButtonSize.width * 2 + actionSpacing + let actionsWidth = actionButtonSize.width * 3 + actionSpacing * 2 let actionsX = max(midpoint, bounds.width - actionsWidth - Metrics.actionRightInset) let buttonY = (bounds.height - actionButtonSize.height) / 2 @@ -225,6 +254,12 @@ private final class HistoryRowView: NSView { width: actionButtonSize.width, height: actionButtonSize.height ) + deleteButton.frame = NSRect( + x: replayButton.frame.maxX + actionSpacing, + y: buttonY, + width: actionButtonSize.width, + height: actionButtonSize.height + ) let textWidth = max(0, actionsX - textLeft - Metrics.textToActionSpacing) titleLabel.frame = NSRect( @@ -246,6 +281,7 @@ private final class HistoryRowView: NSView { subtitleLabel.stringValue = subtitle revealButton.session = session replayButton.session = session + deleteButton.session = session } private func setupUI() { @@ -276,6 +312,13 @@ private final class HistoryRowView: NSView { ) replayButton.toolTip = L10n.recording.replay addSubview(replayButton) + + deleteButton.image = NSImage( + systemSymbolName: "trash", + accessibilityDescription: L10n.recording.delete + ) + deleteButton.toolTip = L10n.recording.delete + addSubview(deleteButton) } } @@ -283,6 +326,7 @@ private final class SessionIconButton: SessionButton { enum Style { case primary case secondary + case destructive } private let style: Style @@ -331,6 +375,9 @@ private final class SessionIconButton: SessionButton { case .secondary: layer?.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.75).cgColor contentTintColor = isEnabled ? .labelColor : .disabledControlTextColor + case .destructive: + layer?.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.75).cgColor + contentTintColor = isEnabled ? .systemRed : .disabledControlTextColor } } } @@ -356,11 +403,12 @@ extension RecordingHistoryPopoverView: NSTableViewDataSource, NSTableViewDelegat } private enum Metrics { - static let preferredWidth: CGFloat = 520 - static let preferredHeight: CGFloat = 360 - static let minimumContentWidth: CGFloat = 320 + static let preferredWidth: CGFloat = 560 + static let preferredHeight: CGFloat = 380 + static let minimumContentWidth: CGFloat = 360 static let columnIdentifier = NSUserInterfaceItemIdentifier("recordingHistory") static let contentInset: CGFloat = 16 + static let summaryReservedHeight: CGFloat = 18 static let rowSpacing: CGFloat = 8 static let rowHeight: CGFloat = 72 static let emptyStateYOffset: CGFloat = 14 diff --git a/ScreenPresenter/Views/Components/RecordingReplayView+Playback.swift b/ScreenPresenter/Views/Components/RecordingReplayView+Playback.swift index 67527b4..30fc9b5 100644 --- a/ScreenPresenter/Views/Components/RecordingReplayView+Playback.swift +++ b/ScreenPresenter/Views/Components/RecordingReplayView+Playback.swift @@ -11,9 +11,9 @@ import AppKit extension RecordingReplayView { func seek(to time: TimeInterval) { - let duration = session?.duration ?? audioPlayer?.duration ?? 0 + let duration = session?.duration ?? clock?.duration ?? 0 let clampedTime = min(max(0, time), duration) - audioPlayer?.currentTime = clampedTime + clock?.currentTime = clampedTime refreshPlaybackState(at: clampedTime) } @@ -30,22 +30,40 @@ extension RecordingReplayView { } } - func updatePlayPauseButton(isPlaying: Bool) { - let title = isPlaying ? L10n.recording.pause : L10n.recording.play - let symbolName = isPlaying ? "pause.fill" : "play.fill" + /// 播放按钮的三种形态:播放中(暂停图标)、已暂停(播放图标)、已播完(重播图标)。 + enum PlaybackButtonState { + case playing + case paused + case ended + } + + func updatePlayPauseButton(_ state: PlaybackButtonState) { + let symbolName: String + let title: String + switch state { + case .playing: + symbolName = "pause.fill" + title = L10n.recording.pause + case .paused: + symbolName = "play.fill" + title = L10n.recording.play + case .ended: + symbolName = "arrow.counterclockwise" + title = L10n.recording.replay + } playPauseButton.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) playPauseButton.toolTip = title } func startAutoPlayback() { - guard let audioPlayer else { return } + guard let clock else { return } - audioPlayer.rate = playbackRate - if audioPlayer.play() { + clock.rate = playbackRate + if clock.play() { startPlaybackTimer() - updatePlayPauseButton(isPlaying: true) + updatePlayPauseButton(.playing) } else { - updatePlayPauseButton(isPlaying: false) + updatePlayPauseButton(.paused) } } @@ -89,7 +107,8 @@ extension RecordingReplayView { } pane.currentSnapshotURL = fileURL - if let cachedImage = imageCache[fileURL] { + let key = fileURL as NSURL + if let cachedImage = imageCache.object(forKey: key) { pane.imageView.image = cachedImage return } @@ -99,7 +118,7 @@ extension RecordingReplayView { return } - imageCache[fileURL] = image + imageCache.setObject(image, forKey: key, cost: image.estimatedByteCost) pane.imageView.image = image } @@ -109,30 +128,30 @@ extension RecordingReplayView { } @objc func playPauseTapped() { - guard let audioPlayer else { return } + guard let clock else { return } - if audioPlayer.isPlaying { - audioPlayer.pause() + if clock.isPlaying { + clock.pause() playbackTimer?.invalidate() playbackTimer = nil - updatePlayPauseButton(isPlaying: false) + updatePlayPauseButton(.paused) } else { - if audioPlayer.currentTime >= audioPlayer.duration { + if clock.currentTime >= clock.duration { seek(to: 0) } - audioPlayer.rate = playbackRate - audioPlayer.play() + clock.rate = playbackRate + clock.play() startPlaybackTimer() - updatePlayPauseButton(isPlaying: true) + updatePlayPauseButton(.playing) } } @objc func rewindTapped() { - seek(to: (audioPlayer?.currentTime ?? progressSlider.doubleValue) - 5) + seek(to: (clock?.currentTime ?? progressSlider.doubleValue) - 5) } @objc func forwardTapped() { - seek(to: (audioPlayer?.currentTime ?? progressSlider.doubleValue) + 5) + seek(to: (clock?.currentTime ?? progressSlider.doubleValue) + 5) } @objc func speedTapped() { @@ -147,7 +166,7 @@ extension RecordingReplayView { playbackRate = 1.0 } - audioPlayer?.rate = playbackRate + clock?.rate = playbackRate updateSpeedButtonTitle() } @@ -156,13 +175,21 @@ extension RecordingReplayView { } @objc func playbackTimerFired() { - guard let audioPlayer else { return } + guard let clock else { return } - refreshPlaybackState(at: audioPlayer.currentTime) - if !audioPlayer.isPlaying { - playbackTimer?.invalidate() - playbackTimer = nil - updatePlayPauseButton(isPlaying: false) - } + refreshPlaybackState(at: clock.currentTime) + + // 手动暂停会在 playPauseTapped 中同步失效定时器,不会走到这里; + // 因此定时器一旦观测到「不再播放」或「已到末尾」,必定是自然播完。 + // 音频时钟播完后 isPlaying 变 false;虚拟时钟无自停机制,靠 currentTime 到达 duration 判定。 + let reachedEnd = !clock.isPlaying || clock.currentTime >= clock.duration + guard reachedEnd else { return } + + clock.pause() // 冻结虚拟时钟于末尾;对音频时钟是空操作。 + playbackTimer?.invalidate() + playbackTimer = nil + // 对齐进度条与时间标签到末尾,避免停在 24.75s / 25s 这类中间态。 + refreshPlaybackState(at: clock.duration) + updatePlayPauseButton(.ended) } } diff --git a/ScreenPresenter/Views/Components/RecordingReplayView.swift b/ScreenPresenter/Views/Components/RecordingReplayView.swift index 71dbb3a..20d6f2e 100644 --- a/ScreenPresenter/Views/Components/RecordingReplayView.swift +++ b/ScreenPresenter/Views/Components/RecordingReplayView.swift @@ -10,6 +10,7 @@ import AVFoundation import AppKit +import QuartzCore // MARK: - 录制回看视图 @@ -41,11 +42,18 @@ final class RecordingReplayView: NSView { // MARK: - 播放状态 var session: RecordingSession? - var audioPlayer: AVAudioPlayer? + var clock: ReplayClock? var playbackTimer: Timer? private var keyboardMonitor: Any? var devicePanes: [DevicePane] = [] - var imageCache: [URL: NSImage] = [:] + /// 回放截图缓存。用 NSCache 限制条目数与总字节,避免长录制回放时内存无上限增长, + /// 内存吃紧时系统会自动回收。 + let imageCache: NSCache = { + let cache = NSCache() + cache.countLimit = 60 + cache.totalCostLimit = 256 * 1024 * 1024 + return cache + }() var playbackRate: Float = 1.0 // MARK: - 初始化 @@ -95,7 +103,7 @@ final class RecordingReplayView: NSView { stopPlayback() self.session = session - imageCache.removeAll() + imageCache.removeAllObjects() playbackRate = 1.0 titleLabel.stringValue = L10n.recording.replayTitle(session.directory.lastPathComponent) @@ -109,18 +117,24 @@ final class RecordingReplayView: NSView { updatePlaybackControls(isEnabled: false) rebuildDevicePanes(for: session.deviceTracks) - do { - let player = try AVAudioPlayer(contentsOf: session.audioURL) + if let audioURL = session.audioURL, let player = try? AVAudioPlayer(contentsOf: audioURL) { player.enableRate = true player.rate = playbackRate player.prepareToPlay() - audioPlayer = player + clock = AudioReplayClock(player: player) setErrorMessage(nil) updatePlaybackControls(isEnabled: true) refreshPlaybackState(at: 0) startAutoPlayback() - } catch { - audioPlayer = nil + } else if session.duration > 0 { + // 纯截图录制或音频加载失败:用虚拟时钟按截图时间轴回放。 + clock = VirtualReplayClock(duration: session.duration) + setErrorMessage(nil) + updatePlaybackControls(isEnabled: true) + refreshPlaybackState(at: 0) + startAutoPlayback() + } else { + clock = nil setErrorMessage(L10n.recording.replayLoadFailed) } } @@ -129,10 +143,10 @@ final class RecordingReplayView: NSView { playbackTimer?.invalidate() playbackTimer = nil - audioPlayer?.stop() - audioPlayer = nil + clock?.pause() + clock = nil - updatePlayPauseButton(isPlaying: false) + updatePlayPauseButton(.paused) updatePlaybackControls(isEnabled: false) } @@ -273,7 +287,7 @@ final class RecordingReplayView: NSView { totalTimeLabel.textColor = .secondaryLabelColor controlsContainer.addSubview(totalTimeLabel) - updatePlayPauseButton(isPlaying: false) + updatePlayPauseButton(.paused) } func configureButton(_ button: NSButton, title: String, action: Selector) { @@ -303,3 +317,99 @@ final class RecordingReplayView: NSView { controlsContainer.addSubview(button) } } + +extension NSImage { + /// 估算位图占用字节数(像素数 × 4),作为 NSCache 的 cost。取最大的位图表示, + /// 无位图表示时回退到点尺寸估算。 + var estimatedByteCost: Int { + let pixelCost = representations + .map { $0.pixelsWide * $0.pixelsHigh } + .max() ?? 0 + if pixelCost > 0 { + return pixelCost * 4 + } + return Int(size.width * size.height) * 4 + } +} + +// MARK: - 回放时钟 + +/// 回放时间轴抽象。有音频时由 AVAudioPlayer 驱动,无音频时由虚拟时钟驱动, +/// 使回放控制逻辑无需区分是否存在音频轨道。 +protocol ReplayClock: AnyObject { + var currentTime: TimeInterval { get set } + var duration: TimeInterval { get } + var rate: Float { get set } + var isPlaying: Bool { get } + @discardableResult func play() -> Bool + func pause() +} + +/// 音频驱动时钟,直接转发到 AVAudioPlayer。 +final class AudioReplayClock: ReplayClock { + private let player: AVAudioPlayer + + init(player: AVAudioPlayer) { + self.player = player + } + + var currentTime: TimeInterval { + get { player.currentTime } + set { player.currentTime = newValue } + } + + var duration: TimeInterval { player.duration } + + var rate: Float { + get { player.rate } + set { player.rate = newValue } + } + + var isPlaying: Bool { player.isPlaying } + + @discardableResult + func play() -> Bool { player.play() } + func pause() { player.pause() } +} + +/// 虚拟时钟,用于纯截图录制(无音频)的回放。基于系统单调时钟按倍速推进。 +final class VirtualReplayClock: ReplayClock { + let duration: TimeInterval + var rate: Float = 1.0 + + private var playing = false + private var anchorMediaTime: TimeInterval = 0 + private var anchorPlaybackTime: TimeInterval = 0 + + init(duration: TimeInterval) { + self.duration = max(0, duration) + } + + var isPlaying: Bool { playing } + + var currentTime: TimeInterval { + get { + guard playing else { return anchorPlaybackTime } + let elapsed = (CACurrentMediaTime() - anchorMediaTime) * Double(rate) + return min(max(0, anchorPlaybackTime + elapsed), duration) + } + set { + anchorPlaybackTime = min(max(0, newValue), duration) + anchorMediaTime = CACurrentMediaTime() + } + } + + @discardableResult + func play() -> Bool { + guard !playing else { return true } + anchorMediaTime = CACurrentMediaTime() + playing = true + return true + } + + func pause() { + guard playing else { return } + anchorPlaybackTime = currentTime + playing = false + } +} diff --git a/ScreenPresenter/Views/Components/ToastView.swift b/ScreenPresenter/Views/Components/ToastView.swift index 6fee5b4..0381007 100644 --- a/ScreenPresenter/Views/Components/ToastView.swift +++ b/ScreenPresenter/Views/Components/ToastView.swift @@ -46,12 +46,17 @@ final class ToastView: NSView { private let iconView: NSImageView private let label: NSTextField private let copyButton: NSButton + private let actionButton: NSButton // MARK: - 属性 private let message: String private let style: ToastStyle private let copyable: Bool + /// 尾部动作按钮标题;为 nil 时不展示。 + private let actionTitle: String? + /// 点击动作按钮后的回调。执行后 Toast 立即消失。 + private var actionHandler: (() -> Void)? private let contentInsets = NSEdgeInsets(top: 12, left: 14, bottom: 12, right: 14) private let iconSize: CGFloat = 18 private let copySize: CGFloat = 20 @@ -66,25 +71,37 @@ final class ToastView: NSView { // MARK: - 初始化 - init(message: String, style: ToastStyle = .info, copyable: Bool = false) { + init( + message: String, + style: ToastStyle = .info, + copyable: Bool = false, + actionTitle: String? = nil, + actionHandler: (() -> Void)? = nil + ) { self.message = message self.style = style self.copyable = copyable + self.actionTitle = actionTitle + self.actionHandler = actionHandler // 初始化 UI 组件 iconView = NSImageView() label = NSTextField(labelWithString: message) copyButton = NSButton() + actionButton = NSButton() super.init(frame: .zero) setupUI() - // 只有 error 类型才启用鼠标追踪(悬停时暂停消失计时) - if style == .error { + // error 类型,或带动作按钮时,启用鼠标追踪(悬停暂停消失计时,给用户点击时间) + if style == .error || hasAction { setupTrackingArea() } } + /// 是否展示尾部动作按钮。 + private var hasAction: Bool { actionTitle?.isEmpty == false } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -131,11 +148,25 @@ final class ToastView: NSView { copyButton.isHidden = !copyable copyButton.setContentHuggingPriority(.required, for: .horizontal) + // 动作按钮:链接样式的文字按钮,点击执行回调并消失 + actionButton.title = actionTitle ?? "" + actionButton.bezelStyle = .inline + actionButton.isBordered = false + actionButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold) + actionButton.contentTintColor = style.iconColor + actionButton.target = self + actionButton.action = #selector(actionTapped) + actionButton.isHidden = !hasAction + actionButton.setContentHuggingPriority(.required, for: .horizontal) + addSubview(iconView) addSubview(label) if copyable { addSubview(copyButton) } + if hasAction { + addSubview(actionButton) + } } // MARK: - 鼠标追踪 @@ -155,8 +186,8 @@ final class ToastView: NSView { override func updateTrackingAreas() { super.updateTrackingAreas() - // 只有 error 类型才需要更新追踪区域 - guard style == .error else { return } + // error 类型或带动作按钮时才需要更新追踪区域 + guard style == .error || hasAction else { return } if let trackingArea { removeTrackingArea(trackingArea) @@ -216,10 +247,9 @@ final class ToastView: NSView { private func layoutContent() { let availableWidth = bounds.width - contentInsets.left - contentInsets.right let copyWidth = copyable ? copySize : 0 - let labelWidth = max( - 0, - availableWidth - iconSize - spacing - (copyable ? (spacing + copyWidth) : 0) - ) + let actionWidth = hasAction ? actionButtonSize().width : 0 + let trailingWidth = (copyable ? (spacing + copyWidth) : 0) + (hasAction ? (spacing + actionWidth) : 0) + let labelWidth = max(0, availableWidth - iconSize - spacing - trailingWidth) let labelSize = labelSize(maxWidth: labelWidth) let contentHeight = max(iconSize, labelSize.height, copyWidth) @@ -230,29 +260,42 @@ final class ToastView: NSView { let labelY = contentInsets.bottom + (contentHeight - labelSize.height) / 2 label.frame = CGRect(x: labelX, y: labelY, width: labelWidth, height: labelSize.height) + var trailingX = label.frame.maxX if copyable { - let copyX = label.frame.maxX + spacing + let copyX = trailingX + spacing let copyY = contentInsets.bottom + (contentHeight - copySize) / 2 copyButton.frame = CGRect(x: copyX, y: copyY, width: copySize, height: copySize) + trailingX = copyButton.frame.maxX + } + if hasAction { + let actionHeight = actionButtonSize().height + let actionX = trailingX + spacing + let actionY = contentInsets.bottom + (contentHeight - actionHeight) / 2 + actionButton.frame = CGRect(x: actionX, y: actionY, width: actionWidth, height: actionHeight) } } + /// 动作按钮的内在尺寸(用于布局与宽度预算)。 + private func actionButtonSize() -> CGSize { + guard hasAction else { return .zero } + return actionButton.intrinsicContentSize + } + private func preferredSize(maxWidth: CGFloat) -> CGSize { // 计算内容区域的最大可用宽度 let availableContentWidth = maxWidth - contentInsets.left - contentInsets.right let copyWidth = copyable ? copySize : 0 - let maxLabelWidth = max( - 0, - availableContentWidth - iconSize - spacing - (copyable ? (spacing + copyWidth) : 0) - ) + let actionWidth = hasAction ? actionButtonSize().width : 0 + let trailingWidth = (copyable ? (spacing + copyWidth) : 0) + (hasAction ? (spacing + actionWidth) : 0) + let maxLabelWidth = max(0, availableContentWidth - iconSize - spacing - trailingWidth) // 获取文本实际需要的尺寸 let labelSize = labelSize(maxWidth: maxLabelWidth) - let contentHeight = max(iconSize, labelSize.height, copyWidth) + let contentHeight = max(iconSize, labelSize.height, copyWidth, hasAction ? actionButtonSize().height : 0) let height = contentInsets.top + contentHeight + contentInsets.bottom // 根据实际文本宽度计算总宽度 - let actualContentWidth = iconSize + spacing + labelSize.width + (copyable ? (spacing + copyWidth) : 0) + let actualContentWidth = iconSize + spacing + labelSize.width + trailingWidth let actualWidth = contentInsets.left + actualContentWidth + contentInsets.right // 返回实际需要的宽度,但不超过最大宽度 @@ -286,6 +329,14 @@ final class ToastView: NSView { } } + @objc private func actionTapped() { + // 先取消消失计时,执行回调后立即消失。 + dismissWorkItem?.cancel() + dismissWorkItem = nil + actionHandler?() + dismiss() + } + // MARK: - 静态方法 /// 在指定视图中显示 Toast @@ -301,11 +352,19 @@ final class ToastView: NSView { style: ToastStyle = .info, copyable: Bool = false, duration: TimeInterval = 2.5, + actionTitle: String? = nil, + actionHandler: (() -> Void)? = nil, in view: NSView? ) { guard let view else { return } - let toast = ToastView(message: message, style: style, copyable: copyable) + let toast = ToastView( + message: message, + style: style, + copyable: copyable, + actionTitle: actionTitle, + actionHandler: actionHandler + ) toast.duration = duration toast.alphaValue = 0 view.addSubview(toast) @@ -384,6 +443,25 @@ final class ToastView: NSView { show(message, style: .success, in: window) } + /// 显示带动作按钮的成功提示(延长展示时长,悬停暂停消失,留出点击时间)。 + @MainActor + static func success( + _ message: String, + actionTitle: String, + duration: TimeInterval = 5.0, + in window: NSWindow?, + actionHandler: @escaping () -> Void + ) { + show( + message, + style: .success, + duration: duration, + actionTitle: actionTitle, + actionHandler: actionHandler, + in: window?.contentView + ) + } + /// 显示错误提示 @MainActor static func error(_ message: String, copyable: Bool = true, in window: NSWindow?) { diff --git a/ScreenPresenter/Views/PreferencesWindowController.swift b/ScreenPresenter/Views/PreferencesWindowController.swift index 6b47ec2..dc62562 100644 --- a/ScreenPresenter/Views/PreferencesWindowController.swift +++ b/ScreenPresenter/Views/PreferencesWindowController.swift @@ -677,6 +677,7 @@ final class PreferencesViewController: NSViewController { let labels = [ L10n.prefs.tab.general, L10n.prefs.tab.capture, + L10n.prefs.tab.recording, L10n.prefs.tab.permissions, ] segmentedControl.segmentCount = labels.count @@ -699,6 +700,7 @@ final class PreferencesViewController: NSViewController { tabViews = [ createGeneralView(), createCaptureView(), + createRecordingView(), createPermissionsView(), ] @@ -1233,10 +1235,131 @@ final class PreferencesViewController: NSViewController { // MARK: - 权限设置 + // MARK: - 投屏录制分页 + + /// 录制保存位置路径标签的 tag,供选择/重置后回填。 + private static let recordingPathLabelTag = 4601 + /// 截图间隔可选档位(秒)。受整秒时间轴约束,最小 1s。 + private static let recordingIntervalOptions = [1, 2, 5, 10] + + private func createRecordingView() -> NSView { + let scrollView = createScrollView() + let stackView = createVerticalStack() + + // 保存位置组 + let outputGroup = createSettingsGroup(title: L10n.prefs.section.recordingOutput, icon: "folder") + addGroupRow(outputGroup, createLabeledRow(label: L10n.prefs.recording.saveLocation) { + let row = StackContainerView() + row.axis = .horizontal + row.alignment = .centerY + row.spacing = 8 + + let pathLabel = FixedSizeTextField(labelWithString: self.recordingSaveLocationDisplayPath()) + pathLabel.font = NSFont.systemFont(ofSize: 12) + pathLabel.textColor = .secondaryLabelColor + pathLabel.lineBreakMode = .byTruncatingMiddle + pathLabel.tag = Self.recordingPathLabelTag + row.addArrangedSubview(pathLabel) + + let chooseButton = NSButton( + title: L10n.prefs.recording.chooseFolder, + target: self, + action: #selector(self.chooseRecordingFolder(_:)) + ) + chooseButton.bezelStyle = .rounded + chooseButton.setContentHuggingPriority(.required, for: .horizontal) + row.addArrangedSubview(chooseButton) + + let resetButton = NSButton( + title: L10n.prefs.recording.resetToDefault, + target: self, + action: #selector(self.resetRecordingFolder(_:)) + ) + resetButton.bezelStyle = .rounded + resetButton.setContentHuggingPriority(.required, for: .horizontal) + row.addArrangedSubview(resetButton) + return row + }) + addGroupRow(outputGroup, self.recordingNote(L10n.prefs.recording.saveLocationNote), addDivider: false) + addSettingsGroup(outputGroup, to: stackView) + + addRecordingSnapshotGroup(to: stackView) + addRecordingAudioGroup(to: stackView) + + setupScrollViewLayout(scrollView: scrollView, contentView: stackView) + return scrollView + } + + /// 画面截图组:截图间隔 + 画质档。 + private func addRecordingSnapshotGroup(to stackView: StackContainerView) { + let group = createSettingsGroup(title: L10n.prefs.section.recordingSnapshot, icon: "camera") + + // 截图间隔 + addGroupRow(group, createLabeledRow(label: L10n.prefs.recording.snapshotInterval) { + let popup = NSPopUpButton() + for seconds in Self.recordingIntervalOptions { + popup.addItem(withTitle: L10n.prefs.recording.intervalSeconds(seconds)) + } + let current = UserPreferences.shared.recordingSnapshotInterval + popup.selectItem(at: Self.recordingIntervalOptions.firstIndex(of: current) ?? 0) + popup.target = self + popup.action = #selector(self.recordingSnapshotIntervalChanged(_:)) + return popup + }) + addGroupRow(group, self.recordingNote(L10n.prefs.recording.snapshotIntervalNote), addDivider: false) + + // 画质档 + addGroupRow(group, createLabeledRow(label: L10n.prefs.recording.imageQuality) { + let popup = NSPopUpButton() + for quality in RecordingImageQuality.allCases { + popup.addItem(withTitle: quality.displayName) + } + let current = UserPreferences.shared.recordingImageQuality + popup.selectItem(at: RecordingImageQuality.allCases.firstIndex(of: current) ?? 1) + popup.target = self + popup.action = #selector(self.recordingImageQualityChanged(_:)) + return popup + }) + addSettingsGroup(group, to: stackView) + } + + /// 音频组:麦克风录制开关。 + private func addRecordingAudioGroup(to stackView: StackContainerView) { + let group = createSettingsGroup(title: L10n.prefs.section.recordingAudio, icon: "mic") + addGroupRow(group, createCheckboxRow( + label: L10n.prefs.recording.enableMicrophone, + isOn: UserPreferences.shared.recordingMicrophoneEnabled, + action: #selector(recordingMicrophoneToggled(_:)) + ), addDivider: false) + // 说明行独立成行,带底部内边距,与截图组保持一致。 + addGroupRow(group, self.recordingNote(L10n.prefs.recording.enableMicrophoneNote), addDivider: false) + addSettingsGroup(group, to: stackView) + } + + /// 灰色小字说明行,复用帧率说明的边距样式。 + private func recordingNote(_ text: String) -> NSView { + let note = NSTextField(wrappingLabelWithString: text) + note.font = NSFont.systemFont(ofSize: 11) + note.textColor = .secondaryLabelColor + return PaddingView( + contentView: note, + insets: NSEdgeInsets(top: 0, left: 0, bottom: LayoutMetrics.rowVerticalPadding * 1.5, right: 0) + ) + } + private func createPermissionsView() -> NSView { let scrollView = createScrollView() let stackView = createVerticalStack() + // 系统权限组(录制用麦克风,跨平台) + let systemPermGroup = createSettingsGroup(title: L10n.prefs.section.systemPermissions, icon: "mic") + addGroupRow(systemPermGroup, createPermissionRow( + name: L10n.permission.microphoneName, + description: L10n.permission.microphoneDesc, + permissionType: .microphone + ), addDivider: false) + addSettingsGroup(systemPermGroup, to: stackView) + // iOS 权限组 let iosPermGroup = createSettingsGroup(title: L10n.prefs.section.iosPermissions, icon: "apple.logo") addGroupRow(iosPermGroup, createPermissionRow( @@ -1852,6 +1975,40 @@ final class PreferencesViewController: NSViewController { private enum PermissionType { case camera + case microphone + + /// 「打开系统设置」按钮的 tag,也用于反查类型。 + var openButtonTag: Int { + switch self { + case .camera: 1 + case .microphone: 2 + } + } + + /// 「撤销」按钮的 tag。 + var revokeButtonTag: Int { + switch self { + case .camera: 11 + case .microphone: 12 + } + } + + /// 系统隐私设置的锚点 URL。 + var systemPreferencesURL: String { + switch self { + case .camera: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera" + case .microphone: + "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" + } + } + + var revokeHint: String { + switch self { + case .camera: L10n.permission.revokeCameraHint + case .microphone: L10n.permission.revokeMicrophoneHint + } + } } private enum ToolType { @@ -1908,8 +2065,7 @@ final class PreferencesViewController: NSViewController { ) openButton.bezelStyle = .rounded openButton.controlSize = .small - // 摄像头权限按钮 tag - openButton.tag = 1 + openButton.tag = permissionType.openButtonTag rightStack.addArrangedSubview(openButton) // 撤销按钮(已授权时显示) @@ -1921,13 +2077,12 @@ final class PreferencesViewController: NSViewController { revokeButton.bezelStyle = .rounded revokeButton.controlSize = .small revokeButton.isHidden = true - // 摄像头权限撤销按钮 tag - revokeButton.tag = 11 + revokeButton.tag = permissionType.revokeButtonTag rightStack.addArrangedSubview(revokeButton) // 检查权限状态 Task { @MainActor in - let granted = checkCameraPermission() + let granted = checkPermission(permissionType) if granted { statusIcon.image = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil) @@ -1949,12 +2104,15 @@ final class PreferencesViewController: NSViewController { return LabeledRowView(labelView: leftStack, control: rightStack) } - private func checkCameraPermission() -> Bool { - // 检查摄像头权限 - // 注意:iOS 设备 USB 屏幕镜像使用 .muxed 媒体类型,但 .muxed 不支持 authorizationStatus 查询 - // 实际上 .video 权限包含了对 muxed 设备的访问权限 - let status = AVCaptureDevice.authorizationStatus(for: .video) - return status == .authorized + private func checkPermission(_ type: PermissionType) -> Bool { + switch type { + case .camera: + // iOS 设备 USB 屏幕镜像使用 .muxed 媒体类型,但 .muxed 不支持 authorizationStatus 查询; + // 实际上 .video 权限包含了对 muxed 设备的访问权限 + return AVCaptureDevice.authorizationStatus(for: .video) == .authorized + case .microphone: + return AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + } } // MARK: - 操作 @@ -2125,33 +2283,38 @@ final class PreferencesViewController: NSViewController { } @objc private func openSystemPreferences(_ sender: NSButton) { - // 打开系统偏好设置 - 隐私 - 摄像头 - let urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera" - if let url = URL(string: urlString) { + // 按按钮 tag 反查权限类型,打开对应的隐私设置面板 + let type = permissionType(forOpenTag: sender.tag) ?? .camera + if let url = URL(string: type.systemPreferencesURL) { NSWorkspace.shared.open(url) } } @objc private func revokePermission(_ sender: NSButton) { - // 摄像头权限 - let urlString = "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera" - let alertMessage = L10n.permission.revokeCameraHint + let type = permissionType(forRevokeTag: sender.tag) ?? .camera - // 显示提示 let alert = NSAlert() alert.messageText = L10n.permission.revokeTitle - alert.informativeText = alertMessage + alert.informativeText = type.revokeHint alert.alertStyle = .informational alert.addButton(withTitle: L10n.permission.openSystemPrefs) alert.addButton(withTitle: L10n.common.cancel) if alert.runModal() == .alertFirstButtonReturn { - if let url = URL(string: urlString) { + if let url = URL(string: type.systemPreferencesURL) { NSWorkspace.shared.open(url) } } } + private func permissionType(forOpenTag tag: Int) -> PermissionType? { + [.camera, .microphone].first { $0.openButtonTag == tag } + } + + private func permissionType(forRevokeTag tag: Int) -> PermissionType? { + [.camera, .microphone].first { $0.revokeButtonTag == tag } + } + // MARK: - 工具链路径设置 @objc private func useCustomPathChanged(_ sender: NSButton) { @@ -2192,6 +2355,62 @@ final class PreferencesViewController: NSViewController { } } + // MARK: - 投屏录制分页 Actions + + /// 当前保存位置的展示路径:用户设定值或默认 Movies。 + private func recordingSaveLocationDisplayPath() -> String { + let base = UserPreferences.shared.recordingSaveDirectory + ?? FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true).appendingPathComponent("Movies") + return (base.path as NSString).abbreviatingWithTildeInPath + } + + private func refreshRecordingPathLabel() { + guard let label = contentContainer.viewWithTag(Self.recordingPathLabelTag) as? NSTextField else { return } + label.stringValue = recordingSaveLocationDisplayPath() + label.superview?.needsLayout = true + label.superview?.layoutSubtreeIfNeeded() + } + + @objc private func chooseRecordingFolder(_ sender: NSButton) { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = true + panel.message = L10n.prefs.recording.saveLocation + panel.prompt = L10n.common.ok + panel.directoryURL = UserPreferences.shared.recordingSaveDirectory + ?? FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first + + if panel.runModal() == .OK, let url = panel.url { + UserPreferences.shared.recordingSaveDirectory = url + refreshRecordingPathLabel() + } + } + + @objc private func resetRecordingFolder(_ sender: NSButton) { + UserPreferences.shared.recordingSaveDirectory = nil + refreshRecordingPathLabel() + } + + @objc private func recordingSnapshotIntervalChanged(_ sender: NSPopUpButton) { + let index = sender.indexOfSelectedItem + guard index >= 0, index < Self.recordingIntervalOptions.count else { return } + UserPreferences.shared.recordingSnapshotInterval = Self.recordingIntervalOptions[index] + } + + @objc private func recordingImageQualityChanged(_ sender: NSPopUpButton) { + let index = sender.indexOfSelectedItem + let all = RecordingImageQuality.allCases + guard index >= 0, index < all.count else { return } + UserPreferences.shared.recordingImageQuality = all[index] + } + + @objc private func recordingMicrophoneToggled(_ sender: NSButton) { + UserPreferences.shared.recordingMicrophoneEnabled = (sender.state == .on) + } + @objc private func customPathChanged(_ sender: NSTextField) { guard let toolType = toolTypeFromTag(sender.tag) else { return } let path = sender.stringValue diff --git a/ScreenPresenterTests/RecordingLibraryTests.swift b/ScreenPresenterTests/RecordingLibraryTests.swift index c4fd1a2..cf927a3 100644 --- a/ScreenPresenterTests/RecordingLibraryTests.swift +++ b/ScreenPresenterTests/RecordingLibraryTests.swift @@ -112,10 +112,11 @@ final class RecordingLibraryTests: XCTestCase { isDirectory: true ) try FileManager.default.createDirectory(at: invalidSession, withIntermediateDirectories: true) + // 既无 audio.m4a,也无可解析的截图(文件名缺少下划线时间戳),应被整体跳过。 try makeDeviceDirectory( in: invalidSession, named: "Android-Pixel", - snapshotNames: ["000001_2026-07-02_09-10-12.jpg"] + snapshotNames: ["000001.jpg"] ) let library = RecordingLibrary( @@ -130,6 +131,32 @@ final class RecordingLibraryTests: XCTestCase { XCTAssertEqual(sessions[0].deviceTracks[0].snapshots.map(\.elapsedSecond), [1]) } + func testScanIncludesAudiolessSessionAndDerivesDurationFromSnapshots() throws { + // 纯截图录制(无 audio.m4a):会话仍应被收录,时长按最大截图秒数 + 1 估算。 + let directory = temporaryDirectory.appendingPathComponent("2026-07-03 10-00-00", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try makeDeviceDirectory( + in: directory, + named: "iOS-iPhone", + snapshotNames: [ + "000001_2026-07-03_10-00-01.jpg", + "000009_2026-07-03_10-00-09.jpg" + ] + ) + + let library = RecordingLibrary( + rootDirectory: temporaryDirectory, + audioDurationProvider: { _ in XCTFail("无音频会话不应调用音频时长解析"); return 0 } + ) + + let sessions = try library.scanSessions() + + XCTAssertEqual(sessions.count, 1) + XCTAssertNil(sessions[0].audioURL) + XCTAssertEqual(sessions[0].duration, 10) + XCTAssertEqual(sessions[0].deviceTracks[0].snapshots.map(\.elapsedSecond), [1, 9]) + } + private func makeSession(named name: String, duration: TimeInterval) throws -> URL { let directory = temporaryDirectory.appendingPathComponent(name, isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)