diff --git a/.gitignore b/.gitignore index c2df992..2aa79ac 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,7 @@ Temporary Items *.swp *.lock *.log +dist/ # Snippets snippets/ diff --git a/BetterCapture/Model/AppLanguage.swift b/BetterCapture/Model/AppLanguage.swift new file mode 100644 index 0000000..cf52d7c --- /dev/null +++ b/BetterCapture/Model/AppLanguage.swift @@ -0,0 +1,40 @@ +// +// AppLanguage.swift +// BetterCapture +// +// Created by Codex on 30.05.26. +// + +import Foundation + +enum AppLanguage: String, CaseIterable, Identifiable { + case english + case simplifiedChinese + + var id: String { rawValue } + + static var current: AppLanguage { + let rawValue = UserDefaults.standard.string(forKey: "appLanguage") ?? AppLanguage.english.rawValue + return AppLanguage(rawValue: rawValue) ?? .english + } + + var displayName: String { + switch self { + case .english: + return "English" + case .simplifiedChinese: + return "简体中文" + } + } +} + +enum AppText { + static func value(_ english: String, _ simplifiedChinese: String, language: AppLanguage) -> String { + switch language { + case .english: + return english + case .simplifiedChinese: + return simplifiedChinese + } + } +} diff --git a/BetterCapture/Model/ContentSelectionMode.swift b/BetterCapture/Model/ContentSelectionMode.swift index 34c4d4a..73e82ae 100644 --- a/BetterCapture/Model/ContentSelectionMode.swift +++ b/BetterCapture/Model/ContentSelectionMode.swift @@ -22,9 +22,15 @@ enum ContentSelectionMode: String { case selectArea var label: String { + label(language: .english) + } + + func label(language: AppLanguage) -> String { switch self { - case .pickContent: "Pick Content" - case .selectArea: "Select Area" + case .pickContent: + AppText.value("Pick Content", "选择内容", language: language) + case .selectArea: + AppText.value("Select Area", "选择区域", language: language) } } diff --git a/BetterCapture/Model/SettingsStore.swift b/BetterCapture/Model/SettingsStore.swift index 9cc9e1b..51e80ec 100644 --- a/BetterCapture/Model/SettingsStore.swift +++ b/BetterCapture/Model/SettingsStore.swift @@ -152,9 +152,13 @@ enum FrameRate: Int, CaseIterable, Identifiable { var id: Int { rawValue } var displayName: String { + displayName(language: .english) + } + + func displayName(language: AppLanguage) -> String { switch self { case .native: - return "Native" + return AppText.value("Native", "原生", language: language) default: return "\(rawValue) fps" } @@ -187,6 +191,21 @@ enum VideoQuality: String, CaseIterable, Identifiable { var id: String { rawValue } + var displayName: String { + displayName(language: .english) + } + + func displayName(language: AppLanguage) -> String { + switch self { + case .low: + return AppText.value("Low", "低", language: language) + case .medium: + return AppText.value("Medium", "中", language: language) + case .high: + return AppText.value("High", "高", language: language) + } + } + /// Bits-per-pixel multiplier for H.264 var h264BitsPerPixel: Double { switch self { @@ -249,6 +268,21 @@ final class SettingsStore { self.defaults = defaults } + // MARK: - General Settings + + var appLanguage: AppLanguage { + get { + access(keyPath: \.appLanguage) + let rawValue = defaults.string(forKey: "appLanguage") ?? AppLanguage.english.rawValue + return AppLanguage(rawValue: rawValue) ?? .english + } + set { + withMutation(keyPath: \.appLanguage) { + defaults.set(newValue.rawValue, forKey: "appLanguage") + } + } + } + // MARK: - Video Settings var frameRate: FrameRate { diff --git a/BetterCapture/Service/AssetWriter.swift b/BetterCapture/Service/AssetWriter.swift index e83c1af..1551e7e 100644 --- a/BetterCapture/Service/AssetWriter.swift +++ b/BetterCapture/Service/AssetWriter.swift @@ -576,17 +576,29 @@ enum AssetWriterError: LocalizedError { var errorDescription: String? { switch self { case .failedToCreateWriter: - return "Failed to create the asset writer." + return AppText.value("Failed to create the asset writer.", "创建媒体写入器失败。", language: .current) case .writerNotReady: - return "The asset writer is not ready for writing." + return AppText.value("The asset writer is not ready for writing.", "媒体写入器尚未准备好写入。", language: .current) case .failedToStartWriting(let error): - return "Failed to start writing: \(error?.localizedDescription ?? "Unknown error")" + return AppText.value( + "Failed to start writing: \(error?.localizedDescription ?? "Unknown error")", + "开始写入失败:\(error?.localizedDescription ?? "未知错误")", + language: .current + ) case .writingFailed(let error): - return "Writing failed: \(error?.localizedDescription ?? "Unknown error")" + return AppText.value( + "Writing failed: \(error?.localizedDescription ?? "Unknown error")", + "写入失败:\(error?.localizedDescription ?? "未知错误")", + language: .current + ) case .noOutputURL: - return "No output URL was configured." + return AppText.value("No output URL was configured.", "未配置输出位置。", language: .current) case .noFramesWritten: - return "No video frames were captured. Check screen recording permissions." + return AppText.value( + "No video frames were captured. Check screen recording permissions.", + "没有捕捉到视频帧。请检查屏幕录制权限。", + language: .current + ) } } } diff --git a/BetterCapture/Service/CaptureEngine.swift b/BetterCapture/Service/CaptureEngine.swift index 70d9644..840dee3 100644 --- a/BetterCapture/Service/CaptureEngine.swift +++ b/BetterCapture/Service/CaptureEngine.swift @@ -376,15 +376,27 @@ enum CaptureError: LocalizedError { var errorDescription: String? { switch self { case .noContentFilterSelected: - return "No content has been selected for capture. Please use the picker to select a window or display." + return AppText.value( + "No content has been selected for capture. Please use the picker to select a window or display.", + "尚未选择录制内容。请使用选择器选择窗口或显示器。", + language: .current + ) case .failedToCreateStream: - return "Failed to create the capture stream." + return AppText.value("Failed to create the capture stream.", "创建录制流失败。", language: .current) case .captureAlreadyRunning: - return "A capture session is already in progress." + return AppText.value("A capture session is already in progress.", "录制会话已在进行中。", language: .current) case .screenRecordingPermissionDenied: - return "Screen recording permission is required. Please grant permission in System Settings → Privacy & Security → Screen Recording." + return AppText.value( + "Screen recording permission is required. Please grant permission in System Settings > Privacy & Security > Screen Recording.", + "需要屏幕录制权限。请在“系统设置”>“隐私与安全性”>“屏幕录制”中授予权限。", + language: .current + ) case .microphonePermissionDenied: - return "Microphone permission is required. Please grant permission in System Settings → Privacy & Security → Microphone." + return AppText.value( + "Microphone permission is required. Please grant permission in System Settings > Privacy & Security > Microphone.", + "需要麦克风权限。请在“系统设置”>“隐私与安全性”>“麦克风”中授予权限。", + language: .current + ) } } } diff --git a/BetterCapture/Service/NotificationService.swift b/BetterCapture/Service/NotificationService.swift index 1deba14..bf30959 100644 --- a/BetterCapture/Service/NotificationService.swift +++ b/BetterCapture/Service/NotificationService.swift @@ -55,7 +55,7 @@ final class NotificationService: NSObject { // Action to show recording in Finder let showInFinderAction = UNNotificationAction( identifier: NotificationIdentifier.actionShowInFinder, - title: "Show in Finder", + title: AppText.value("Show in Finder", "在访达中显示", language: .current), options: [.foreground] ) @@ -100,9 +100,15 @@ final class NotificationService: NSObject { /// Sends a notification for a successfully saved recording /// - Parameter fileURL: The URL of the saved recording file func sendRecordingSavedNotification(fileURL: URL) { + registerNotificationCategories() + let language = settings.appLanguage let content = UNMutableNotificationContent() - content.title = "Recording Saved" - content.body = "Your recording has been saved to \(fileURL.lastPathComponent)" + content.title = AppText.value("Recording Saved", "录制已保存", language: language) + content.body = AppText.value( + "Your recording has been saved to \(fileURL.lastPathComponent)", + "录制文件已保存到 \(fileURL.lastPathComponent)", + language: language + ) content.sound = .default content.categoryIdentifier = NotificationIdentifier.categoryRecordingSaved @@ -129,9 +135,15 @@ final class NotificationService: NSObject { /// Sends a notification for a failed recording /// - Parameter error: The error that caused the recording to fail func sendRecordingFailedNotification(error: Error) { + registerNotificationCategories() + let language = settings.appLanguage let content = UNMutableNotificationContent() - content.title = "Recording Failed" - content.body = "Your recording could not be saved: \(error.localizedDescription)" + content.title = AppText.value("Recording Failed", "录制失败", language: language) + content.body = AppText.value( + "Your recording could not be saved: \(error.localizedDescription)", + "无法保存录制文件:\(error.localizedDescription)", + language: language + ) content.sound = .default content.categoryIdentifier = NotificationIdentifier.categoryRecordingFailed @@ -154,13 +166,19 @@ final class NotificationService: NSObject { /// Sends a notification when recording stopped unexpectedly /// - Parameter error: Optional error that caused the stop func sendRecordingStoppedNotification(error: Error?) { + registerNotificationCategories() + let language = settings.appLanguage let content = UNMutableNotificationContent() - content.title = "Recording Stopped" + content.title = AppText.value("Recording Stopped", "录制已停止", language: language) if let error { - content.body = "Recording stopped unexpectedly: \(error.localizedDescription)" + content.body = AppText.value( + "Recording stopped unexpectedly: \(error.localizedDescription)", + "录制意外停止:\(error.localizedDescription)", + language: language + ) } else { - content.body = "Recording stopped unexpectedly" + content.body = AppText.value("Recording stopped unexpectedly", "录制意外停止", language: language) } content.sound = .default diff --git a/BetterCapture/View/AreaSelectionOverlay.swift b/BetterCapture/View/AreaSelectionOverlay.swift index c5ce855..1ff793c 100644 --- a/BetterCapture/View/AreaSelectionOverlay.swift +++ b/BetterCapture/View/AreaSelectionOverlay.swift @@ -484,13 +484,13 @@ final class AreaSelectionView: NSView { let container = NSView() let confirm = makeActionButton( - title: "Confirm", + title: AppText.value("Confirm", "确认", language: .current), textColor: .systemGreen, action: #selector(confirmButtonClicked) ) let cancel = makeActionButton( - title: "Cancel", + title: AppText.value("Cancel", "取消", language: .current), textColor: .systemRed, action: #selector(cancelButtonClicked) ) diff --git a/BetterCapture/View/MenuBarSettingsView.swift b/BetterCapture/View/MenuBarSettingsView.swift index 5a31dce..35a1a79 100644 --- a/BetterCapture/View/MenuBarSettingsView.swift +++ b/BetterCapture/View/MenuBarSettingsView.swift @@ -291,6 +291,7 @@ struct DeviceRow: View { struct MicrophoneExpandablePicker: View { @Binding var selectedID: String? let devices: [AudioInputDevice] + let language: AppLanguage @State private var isExpanded = false @State private var isHovered = false @@ -303,7 +304,7 @@ struct MicrophoneExpandablePicker: View { } } label: { HStack { - Text("Microphone") + Text(AppText.value("Microphone", "麦克风", language: language)) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.primary) Spacer() @@ -334,7 +335,7 @@ struct MicrophoneExpandablePicker: View { VStack(spacing: 0) { // System Default option DeviceRow( - name: "System Default", + name: AppText.value("System Default", "系统默认", language: language), icon: "mic", isSelected: selectedID == nil ) { @@ -368,7 +369,7 @@ struct MicrophoneExpandablePicker: View { if let id = selectedID, let device = devices.first(where: { $0.id == id }) { return device.name } - return "System Default" + return AppText.value("System Default", "系统默认", language: language) } } @@ -430,31 +431,38 @@ struct MenuBarExpandableSection: View { /// Video settings section with header and inline content struct VideoSettingsSection: View { @Bindable var settings: SettingsStore + private var language: AppLanguage { settings.appLanguage } var body: some View { VStack(spacing: 0) { - SectionHeader(title: "Video") + SectionHeader(title: AppText.value("Video", "视频", language: language)) // Content Filter Section - MenuBarExpandableSection(title: "Content Filter") { - MenuBarToggle(name: "Show Cursor", isOn: $settings.showCursor) - MenuBarToggle(name: "Show Wallpaper", isOn: $settings.showWallpaper) - MenuBarToggle(name: "Show Menu Bar", isOn: $settings.showMenuBar) - MenuBarToggle(name: "Show Dock", isOn: $settings.showDock) - MenuBarToggle(name: "Show Window Shadows", isOn: $settings.showWindowShadows) - MenuBarToggle(name: "Show BetterCapture", isOn: $settings.showBetterCapture) + MenuBarExpandableSection(title: AppText.value("Content Filter", "内容过滤", language: language)) { + MenuBarToggle(name: AppText.value("Show Cursor", "显示光标", language: language), isOn: $settings.showCursor) + MenuBarToggle(name: AppText.value("Show Wallpaper", "显示壁纸", language: language), isOn: $settings.showWallpaper) + MenuBarToggle(name: AppText.value("Show Menu Bar", "显示菜单栏", language: language), isOn: $settings.showMenuBar) + MenuBarToggle(name: AppText.value("Show Dock", "显示 Dock", language: language), isOn: $settings.showDock) + MenuBarToggle( + name: AppText.value("Show Window Shadows", "显示窗口阴影", language: language), + isOn: $settings.showWindowShadows + ) + MenuBarToggle( + name: AppText.value("Show BetterCapture", "显示 BetterCapture", language: language), + isOn: $settings.showBetterCapture + ) } // Frame Rate Picker MenuBarExpandablePicker( - name: "Frame Rate", + name: AppText.value("Frame Rate", "帧率", language: language), selection: $settings.frameRate, - options: FrameRate.allCases.map { ($0, $0.displayName) } + options: FrameRate.allCases.map { ($0, $0.displayName(language: language)) } ) // Video Codec Picker (shows all codecs, disables incompatible ones) MenuBarExpandablePicker( - name: "Codec", + name: AppText.value("Codec", "编码器", language: language), selection: $settings.videoCodec, optionsWithState: VideoCodec.allCases.map { codec in let isSupported = settings.containerFormat.supportedVideoCodecs.contains(codec) @@ -462,28 +470,34 @@ struct VideoSettingsSection: View { value: codec, label: codec.rawValue, isDisabled: !isSupported, - disabledMessage: isSupported ? nil : "Not supported for \(settings.containerFormat.rawValue.uppercased())" + disabledMessage: isSupported + ? nil + : AppText.value( + "Not supported for \(settings.containerFormat.rawValue.uppercased())", + "\(settings.containerFormat.rawValue.uppercased()) 不支持", + language: language + ) ) } ) // Container Format Picker MenuBarExpandablePicker( - name: "Container", + name: AppText.value("Container", "封装格式", language: language), selection: $settings.containerFormat, options: ContainerFormat.allCases.map { ($0, $0.rawValue.uppercased()) } ) // Alpha Channel Toggle (disabled if codec doesn't support or container doesn't support) MenuBarToggle( - name: "Capture Alpha Channel", + name: AppText.value("Capture Alpha Channel", "录制 Alpha 通道", language: language), isOn: $settings.captureAlphaChannel, isDisabled: !settings.videoCodec.canToggleAlpha || !settings.containerFormat.supportsAlphaChannel ) // HDR Recording Toggle (disabled for codecs that don't support HDR) MenuBarToggle( - name: "HDR Recording", + name: AppText.value("HDR Recording", "HDR 录制", language: language), isOn: $settings.captureHDR, isDisabled: !settings.videoCodec.supportsHDR ) @@ -497,31 +511,33 @@ struct VideoSettingsSection: View { struct AudioSettingsSection: View { @Bindable var settings: SettingsStore let audioDeviceService: AudioDeviceService + private var language: AppLanguage { settings.appLanguage } var body: some View { VStack(spacing: 0) { // Separator before Audio section SectionDivider() - SectionHeader(title: "Audio") + SectionHeader(title: AppText.value("Audio", "音频", language: language)) // System Audio Toggle - MenuBarToggle(name: "Capture System Audio", isOn: $settings.captureSystemAudio) + MenuBarToggle(name: AppText.value("Capture System Audio", "录制系统音频", language: language), isOn: $settings.captureSystemAudio) // Microphone Toggle - MenuBarToggle(name: "Capture Microphone", isOn: $settings.captureMicrophone) + MenuBarToggle(name: AppText.value("Capture Microphone", "录制麦克风", language: language), isOn: $settings.captureMicrophone) // Microphone Source Picker (only shown when microphone is enabled) if settings.captureMicrophone { MicrophoneExpandablePicker( selectedID: $settings.selectedMicrophoneID, - devices: audioDeviceService.availableDevices + devices: audioDeviceService.availableDevices, + language: language ) } // Audio Codec Picker (shows all codecs, disables incompatible ones) MenuBarExpandablePicker( - name: "Audio Codec", + name: AppText.value("Audio Codec", "音频编码器", language: language), selection: $settings.audioCodec, optionsWithState: AudioCodec.allCases.map { codec in let isSupported = settings.containerFormat.supportedAudioCodecs.contains(codec) @@ -529,7 +545,13 @@ struct AudioSettingsSection: View { value: codec, label: codec.rawValue, isDisabled: !isSupported, - disabledMessage: isSupported ? nil : "Not supported for \(settings.containerFormat.rawValue.uppercased())" + disabledMessage: isSupported + ? nil + : AppText.value( + "Not supported for \(settings.containerFormat.rawValue.uppercased())", + "\(settings.containerFormat.rawValue.uppercased()) 不支持", + language: language + ) ) } ) @@ -543,6 +565,7 @@ struct AudioSettingsSection: View { struct CameraExpandablePicker: View { @Binding var selectedID: String? let devices: [CameraDevice] + let language: AppLanguage @State private var isExpanded = false @State private var isHovered = false @@ -555,7 +578,7 @@ struct CameraExpandablePicker: View { } } label: { HStack { - Text("Camera") + Text(AppText.value("Camera", "摄像头", language: language)) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.primary) Spacer() @@ -586,7 +609,7 @@ struct CameraExpandablePicker: View { VStack(spacing: 0) { // System Default option DeviceRow( - name: "System Default", + name: AppText.value("System Default", "系统默认", language: language), icon: "camera", isSelected: selectedID == nil ) { @@ -620,7 +643,7 @@ struct CameraExpandablePicker: View { if let id = selectedID, let device = devices.first(where: { $0.id == id }) { return device.name } - return "System Default" + return AppText.value("System Default", "系统默认", language: language) } } @@ -630,19 +653,21 @@ struct CameraExpandablePicker: View { struct PresenterOverlaySettingsSection: View { @Bindable var settings: SettingsStore let cameraDeviceService: CameraDeviceService + private var language: AppLanguage { settings.appLanguage } var body: some View { VStack(spacing: 0) { SectionDivider() - SectionHeader(title: "Camera") + SectionHeader(title: AppText.value("Camera", "摄像头", language: language)) - MenuBarToggle(name: "Presenter Overlay", isOn: $settings.presenterOverlayEnabled) + MenuBarToggle(name: AppText.value("Presenter Overlay", "演示者叠加层", language: language), isOn: $settings.presenterOverlayEnabled) if settings.presenterOverlayEnabled { CameraExpandablePicker( selectedID: $settings.selectedCameraID, - devices: cameraDeviceService.availableDevices + devices: cameraDeviceService.availableDevices, + language: language ) } } diff --git a/BetterCapture/View/MenuBarView.swift b/BetterCapture/View/MenuBarView.swift index 64d827a..fb93bc4 100644 --- a/BetterCapture/View/MenuBarView.swift +++ b/BetterCapture/View/MenuBarView.swift @@ -16,6 +16,7 @@ struct MenuBarView: View { @State private var currentPreview: NSImage? private var isRecording: Bool { viewModel.isRecording } + private var language: AppLanguage { viewModel.settings.appLanguage } var body: some View { VStack(spacing: 0) { @@ -25,7 +26,8 @@ struct MenuBarView: View { (viewModel.settings.captureMicrophone && viewModel.permissionService.microphoneState != .granted) { PermissionStatusBanner( permissionService: viewModel.permissionService, - showMicrophonePermission: viewModel.settings.captureMicrophone + showMicrophonePermission: viewModel.settings.captureMicrophone, + language: language ) MenuBarDivider() } @@ -33,7 +35,8 @@ struct MenuBarView: View { // Recording button (stop + timer) or Start button if isRecording { RecordingButton( - duration: viewModel.formattedDuration + duration: viewModel.formattedDuration, + language: language ) { Task { await viewModel.stopRecording() @@ -42,7 +45,7 @@ struct MenuBarView: View { .padding(.top, 8) } else { MenuBarActionButton( - title: "Start Recording", + title: AppText.value("Start Recording", "开始录制", language: language), systemImage: "record.circle", accentColor: .green, isDisabled: !viewModel.canStartRecording @@ -58,7 +61,7 @@ struct MenuBarView: View { MenuBarDivider() // Content Selection - ContentSelectionButton(viewModel: viewModel) { dismiss() } + ContentSelectionButton(viewModel: viewModel, language: language) { dismiss() } .disabled(isRecording) // Preview thumbnail @@ -66,6 +69,7 @@ struct MenuBarView: View { PreviewThumbnailView( previewImage: currentPreview, isLivePreviewActive: viewModel.previewService.isCapturing, + language: language, onStartLivePreview: { Task { await viewModel.startPreview() @@ -89,7 +93,7 @@ struct MenuBarView: View { await viewModel.resetAreaSelection() } } label: { - Text("Reset Selection") + Text(AppText.value("Reset Selection", "重置选择", language: language)) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.red) .frame(maxWidth: .infinity) @@ -122,7 +126,10 @@ struct MenuBarView: View { MenuBarDivider() // Bottom Actions - MenuBarActionButton(title: "Open Output Folder", systemImage: "folder") { + MenuBarActionButton( + title: AppText.value("Open Output Folder", "打开输出文件夹", language: language), + systemImage: "folder" + ) { let settings = viewModel.settings let didStart = settings.startAccessingOutputDirectory() defer { @@ -133,12 +140,12 @@ struct MenuBarView: View { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: settings.outputDirectory.path) } - MenuBarActionButton(title: "Settings...", systemImage: "gear") { + MenuBarActionButton(title: AppText.value("Settings...", "设置...", language: language), systemImage: "gear") { NSApplication.shared.activate(ignoringOtherApps: true) openSettings() } - MenuBarActionButton(title: "Quit...", systemImage: "power") { + MenuBarActionButton(title: AppText.value("Quit...", "退出...", language: language), systemImage: "power") { NSApplication.shared.terminate(nil) } .padding(.bottom, 8) @@ -200,6 +207,7 @@ struct MenuBarActionButton: View { /// A combined button that shows recording status and allows stopping struct RecordingButton: View { let duration: String + var language: AppLanguage = .english let action: () -> Void @State private var isHovered = false @@ -217,7 +225,7 @@ struct RecordingButton: View { .foregroundStyle(.red.opacity(0.8)) } - Text("Stop Recording") + Text(AppText.value("Stop Recording", "停止录制", language: language)) .font(.system(size: 13, weight: .semibold)) Spacer() @@ -249,6 +257,7 @@ struct RecordingButton: View { /// Styled consistently with other menu bar rows. struct ContentSelectionButton: View { let viewModel: RecorderViewModel + let language: AppLanguage var onDismissPanel: (() -> Void)? @AppStorage(ContentSelectionMode.storageKey) private var mode: ContentSelectionMode = .pickContent @State private var isDropdownExpanded = false @@ -266,7 +275,15 @@ struct ContentSelectionButton: View { } private var buttonLabel: String { - hasActiveSelection ? "Change \(mode.label.split(separator: " ").last, default: "Content")..." : "\(mode.label)..." + if hasActiveSelection { + return AppText.value( + "Change \(mode.label(language: language))...", + "更改\(mode.label(language: language))...", + language: language + ) + } + + return "\(mode.label(language: language))..." } var body: some View { @@ -332,7 +349,7 @@ struct ContentSelectionButton: View { if isDropdownExpanded { VStack(spacing: 0) { DeviceRow( - name: ContentSelectionMode.pickContent.label, + name: ContentSelectionMode.pickContent.label(language: language), icon: ContentSelectionMode.pickContent.icon, isSelected: mode == .pickContent ) { @@ -343,7 +360,7 @@ struct ContentSelectionButton: View { } DeviceRow( - name: ContentSelectionMode.selectArea.label, + name: ContentSelectionMode.selectArea.label(language: language), icon: ContentSelectionMode.selectArea.icon, isSelected: mode == .selectArea ) { @@ -378,13 +395,14 @@ struct ContentSelectionButton: View { struct PermissionStatusBanner: View { let permissionService: PermissionService let showMicrophonePermission: Bool + let language: AppLanguage var body: some View { VStack(spacing: 4) { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) - Text("Permissions Required") + Text(AppText.value("Permissions Required", "需要权限", language: language)) .font(.system(size: 13, weight: .semibold)) Spacer() } @@ -393,8 +411,9 @@ struct PermissionStatusBanner: View { if permissionService.screenRecordingState != .granted { PermissionRow( - title: "Screen Recording", - isGranted: false + title: AppText.value("Screen Recording", "屏幕录制", language: language), + isGranted: false, + language: language ) { permissionService.openScreenRecordingSettings() } @@ -402,8 +421,9 @@ struct PermissionStatusBanner: View { if showMicrophonePermission && permissionService.microphoneState != .granted { PermissionRow( - title: "Microphone", - isGranted: false + title: AppText.value("Microphone", "麦克风", language: language), + isGranted: false, + language: language ) { permissionService.openMicrophoneSettings() } @@ -417,6 +437,7 @@ struct PermissionStatusBanner: View { struct PermissionRow: View { let title: String let isGranted: Bool + let language: AppLanguage let action: () -> Void @State private var isHovered = false @@ -434,7 +455,7 @@ struct PermissionRow: View { Spacer() if !isGranted { - Text("Open Settings") + Text(AppText.value("Open Settings", "打开设置", language: language)) .font(.system(size: 11)) .foregroundStyle(.secondary) } diff --git a/BetterCapture/View/PreviewThumbnailView.swift b/BetterCapture/View/PreviewThumbnailView.swift index 691f6c0..d2eeb78 100644 --- a/BetterCapture/View/PreviewThumbnailView.swift +++ b/BetterCapture/View/PreviewThumbnailView.swift @@ -11,6 +11,7 @@ import SwiftUI struct PreviewThumbnailView: View { let previewImage: NSImage? let isLivePreviewActive: Bool + var language: AppLanguage = .english let onStartLivePreview: () -> Void let onStopLivePreview: () -> Void @@ -76,7 +77,7 @@ struct PreviewThumbnailView: View { VStack { HStack { Spacer() - Text("LIVE") + Text(AppText.value("LIVE", "实时", language: language)) .font(.system(size: 9, weight: .bold)) .foregroundStyle(.white) .padding(.horizontal, 6) diff --git a/BetterCapture/View/RecordingOverlayView.swift b/BetterCapture/View/RecordingOverlayView.swift index 3b89aa9..de5fb7e 100644 --- a/BetterCapture/View/RecordingOverlayView.swift +++ b/BetterCapture/View/RecordingOverlayView.swift @@ -14,6 +14,7 @@ struct RecordingOverlayView: View { let onDismiss: () -> Void @State private var currentPreview: NSImage? + private var language: AppLanguage { viewModel.settings.appLanguage } var body: some View { VStack(spacing: 0) { @@ -53,7 +54,7 @@ struct RecordingOverlayView: View { VStack { HStack { Spacer() - Text("LIVE") + Text(AppText.value("LIVE", "实时", language: language)) .font(.system(size: 9, weight: .bold)) .foregroundStyle(.white) .padding(.horizontal, 6) @@ -73,14 +74,14 @@ struct RecordingOverlayView: View { private var buttonRow: some View { VStack(spacing: 6) { - Button("Start Recording", systemImage: "record.circle") { + Button(AppText.value("Start Recording", "开始录制", language: language), systemImage: "record.circle") { Task { await viewModel.startRecording() } } .buttonStyle(OverlayButtonStyle(labelColor: .green, weight: .semibold)) - Button("Dismiss") { + Button(AppText.value("Dismiss", "关闭", language: language)) { onDismiss() } .buttonStyle(OverlayButtonStyle(labelColor: .secondary, weight: .medium)) diff --git a/BetterCapture/View/SettingsView.swift b/BetterCapture/View/SettingsView.swift index 1f73096..b9e5c90 100644 --- a/BetterCapture/View/SettingsView.swift +++ b/BetterCapture/View/SettingsView.swift @@ -13,23 +13,24 @@ import SwiftUI struct SettingsView: View { @Bindable var settings: SettingsStore var updaterService: UpdaterService + private var language: AppLanguage { settings.appLanguage } var body: some View { TabView { - Tab("General", systemImage: "gearshape") { + Tab(AppText.value("General", "通用", language: language), systemImage: "gearshape") { GeneralSettingsView(settings: settings, updaterService: updaterService) } - Tab("Video", systemImage: "video") { + Tab(AppText.value("Video", "视频", language: language), systemImage: "video") { VideoSettingsView(settings: settings) } - Tab("Audio", systemImage: "waveform") { + Tab(AppText.value("Audio", "音频", language: language), systemImage: "waveform") { AudioSettingsView(settings: settings) } - Tab("Shortcuts", systemImage: "keyboard") { - ShortcutsSettingsView() + Tab(AppText.value("Shortcuts", "快捷键", language: language), systemImage: "keyboard") { + ShortcutsSettingsView(settings: settings) } } .frame(width: 500, height: 420) @@ -39,19 +40,29 @@ struct SettingsView: View { // MARK: - Shortcuts Settings struct ShortcutsSettingsView: View { + @Bindable var settings: SettingsStore + private var language: AppLanguage { settings.appLanguage } + var body: some View { Form { - Section("Recording") { - KeyboardShortcuts.Recorder("Toggle Recording", name: .toggleRecording) + Section(AppText.value("Recording", "录制", language: language)) { + KeyboardShortcuts.Recorder( + AppText.value("Toggle Recording", "切换录制", language: language), + name: .toggleRecording + ) } - Section("Content Selection") { - KeyboardShortcuts.Recorder("Select Content", name: .selectContent) - KeyboardShortcuts.Recorder("Select Area", name: .selectArea) + Section(AppText.value("Content Selection", "内容选择", language: language)) { + KeyboardShortcuts.Recorder(AppText.value("Select Content", "选择内容", language: language), name: .selectContent) + KeyboardShortcuts.Recorder(AppText.value("Select Area", "选择区域", language: language), name: .selectArea) } Section { - Text("Shortcuts work globally, even when BetterCapture is not focused.") + Text(AppText.value( + "Shortcuts work globally, even when BetterCapture is not focused.", + "快捷键全局生效,即使 BetterCapture 当前未获得焦点。", + language: language + )) .font(.caption) .foregroundStyle(.secondary) } @@ -65,100 +76,130 @@ struct ShortcutsSettingsView: View { struct VideoSettingsView: View { @Bindable var settings: SettingsStore + private var language: AppLanguage { settings.appLanguage } private var alphaChannelHelpText: String { switch settings.videoCodec { case .proRes4444: - return "ProRes 4444 always includes alpha channel support" + return AppText.value("ProRes 4444 always includes alpha channel support", "ProRes 4444 始终支持 Alpha 通道", language: language) case .hevc: - return "Enable transparency support for HEVC" + return AppText.value("Enable transparency support for HEVC", "为 HEVC 启用透明度支持", language: language) case .h264, .proRes422: - return "Alpha channel not supported by this codec" + return AppText.value("Alpha channel not supported by this codec", "此编码器不支持 Alpha 通道", language: language) } } private var hdrHelpText: String { if settings.videoCodec.supportsHDR { - return "Enable 10-bit HDR capture for high dynamic range content" + return AppText.value( + "Enable 10-bit HDR capture for high dynamic range content", + "为高动态范围内容启用 10-bit HDR 录制", + language: language + ) } else { - return "HDR is only supported with ProRes 422 and ProRes 4444 codecs" + return AppText.value( + "HDR is only supported with ProRes 422 and ProRes 4444 codecs", + "HDR 仅支持 ProRes 422 和 ProRes 4444 编码器", + language: language + ) } } private var qualityHelpText: String { if settings.videoCodec.supportsQualitySetting { - return "Controls the video bitrate. Higher quality produces sharper output with larger files" + return AppText.value( + "Controls the video bitrate. Higher quality produces sharper output with larger files", + "控制视频码率。质量越高,画面越清晰,文件也越大", + language: language + ) } else { - return "ProRes codecs use fixed-quality encoding" + return AppText.value("ProRes codecs use fixed-quality encoding", "ProRes 编码器使用固定质量编码", language: language) } } - private let captureNativeResHelpText = """ - When enabled, captures at the display's native pixel resolution. \ - When disabled, captures at the logical (1x) resolution. Has no effect on non-Retina displays - """ + private var captureNativeResHelpText: String { + AppText.value( + """ + When enabled, captures at the display's native pixel resolution. \ + When disabled, captures at the logical (1x) resolution. Has no effect on non-Retina displays + """, + """ + 启用后按显示器原生像素分辨率录制。\ + 关闭后按逻辑(1x)分辨率录制。对非 Retina 显示器无影响 + """, + language: language + ) + } var body: some View { Form { - Section("Recording") { - Picker("Frame Rate", selection: $settings.frameRate) { + Section(AppText.value("Recording", "录制", language: language)) { + Picker(AppText.value("Frame Rate", "帧率", language: language), selection: $settings.frameRate) { ForEach(FrameRate.allCases) { rate in - Text(rate.displayName).tag(rate) + Text(rate.displayName(language: language)).tag(rate) } } - Picker("Codec", selection: $settings.videoCodec) { + Picker(AppText.value("Codec", "编码器", language: language), selection: $settings.videoCodec) { ForEach(VideoCodec.allCases) { codec in let isSupported = settings.containerFormat.supportedVideoCodecs.contains(codec) if isSupported { Text(codec.rawValue).tag(codec) } else { - Text("\(codec.rawValue) (not supported for \(settings.containerFormat.rawValue.uppercased()))") + Text(AppText.value( + "\(codec.rawValue) (not supported for \(settings.containerFormat.rawValue.uppercased()))", + "\(codec.rawValue)(\(settings.containerFormat.rawValue.uppercased()) 不支持)", + language: language + )) .foregroundStyle(.secondary) .tag(codec) } } } - Picker("Container", selection: $settings.containerFormat) { + Picker(AppText.value("Container", "封装格式", language: language), selection: $settings.containerFormat) { ForEach(ContainerFormat.allCases) { format in Text(".\(format.rawValue)").tag(format) } } - Picker("Quality", selection: $settings.videoQuality) { + Picker(AppText.value("Quality", "质量", language: language), selection: $settings.videoQuality) { ForEach(VideoQuality.allCases) { quality in - Text(quality.rawValue).tag(quality) + Text(quality.displayName(language: language)).tag(quality) } } .disabled(!settings.videoCodec.supportsQualitySetting) .help(qualityHelpText) } - Section("Advanced") { - Toggle("Capture Alpha Channel", isOn: $settings.captureAlphaChannel) + Section(AppText.value("Advanced", "高级", language: language)) { + Toggle(AppText.value("Capture Alpha Channel", "录制 Alpha 通道", language: language), isOn: $settings.captureAlphaChannel) .disabled(!settings.videoCodec.canToggleAlpha || !settings.containerFormat.supportsAlphaChannel) .help(alphaChannelHelpText) - Toggle("HDR Recording", isOn: $settings.captureHDR) + Toggle(AppText.value("HDR Recording", "HDR 录制", language: language), isOn: $settings.captureHDR) .disabled(!settings.videoCodec.supportsHDR) .help(hdrHelpText) - Toggle("Native Resolution", isOn: $settings.captureNativeResolution) + Toggle(AppText.value("Native Resolution", "原生分辨率", language: language), isOn: $settings.captureNativeResolution) .help(captureNativeResHelpText) } - Section("Display Elements") { - Toggle("Show Cursor", isOn: $settings.showCursor) - Toggle("Show Wallpaper", isOn: $settings.showWallpaper) - Toggle("Show Menu Bar", isOn: $settings.showMenuBar) - Toggle("Show Dock", isOn: $settings.showDock) - Toggle("Show BetterCapture", isOn: $settings.showBetterCapture) + Section(AppText.value("Display Elements", "显示元素", language: language)) { + Toggle(AppText.value("Show Cursor", "显示光标", language: language), isOn: $settings.showCursor) + Toggle(AppText.value("Show Wallpaper", "显示壁纸", language: language), isOn: $settings.showWallpaper) + Toggle(AppText.value("Show Menu Bar", "显示菜单栏", language: language), isOn: $settings.showMenuBar) + Toggle(AppText.value("Show Dock", "显示 Dock", language: language), isOn: $settings.showDock) + Toggle(AppText.value("Show BetterCapture", "显示 BetterCapture", language: language), isOn: $settings.showBetterCapture) } - Section("Window Capture") { - Toggle("Show Window Shadows", isOn: $settings.showWindowShadows) - .help("Include window shadows when capturing individual windows") + Section(AppText.value("Window Capture", "窗口录制", language: language)) { + Toggle(AppText.value("Show Window Shadows", "显示窗口阴影", language: language), isOn: $settings.showWindowShadows) + .help(AppText.value( + "Include window shadows when capturing individual windows", + "录制单个窗口时包含窗口阴影", + language: language + )) } } .formStyle(.grouped) @@ -170,35 +211,48 @@ struct VideoSettingsView: View { struct AudioSettingsView: View { @Bindable var settings: SettingsStore + private var language: AppLanguage { settings.appLanguage } var body: some View { Form { - Section("Sources") { - Toggle("Capture System Audio", isOn: $settings.captureSystemAudio) - .help("Record audio from applications and system sounds") + Section(AppText.value("Sources", "来源", language: language)) { + Toggle(AppText.value("Capture System Audio", "录制系统音频", language: language), isOn: $settings.captureSystemAudio) + .help(AppText.value("Record audio from applications and system sounds", "录制应用程序和系统声音", language: language)) - Toggle("Capture Microphone", isOn: $settings.captureMicrophone) - .help("Record audio from the default microphone input") + Toggle(AppText.value("Capture Microphone", "录制麦克风", language: language), isOn: $settings.captureMicrophone) + .help(AppText.value("Record audio from the default microphone input", "录制默认麦克风输入", language: language)) } - Section("Format") { - Picker("Codec", selection: $settings.audioCodec) { + Section(AppText.value("Format", "格式", language: language)) { + Picker(AppText.value("Codec", "编码器", language: language), selection: $settings.audioCodec) { ForEach(AudioCodec.allCases) { codec in let isSupported = settings.containerFormat.supportedAudioCodecs.contains(codec) if isSupported { Text(codec.rawValue).tag(codec) } else { - Text("\(codec.rawValue) (not supported for \(settings.containerFormat.rawValue.uppercased()))") + Text(AppText.value( + "\(codec.rawValue) (not supported for \(settings.containerFormat.rawValue.uppercased()))", + "\(codec.rawValue)(\(settings.containerFormat.rawValue.uppercased()) 不支持)", + language: language + )) .foregroundStyle(.secondary) .tag(codec) } } } - .help("AAC is compressed, PCM is uncompressed lossless (MOV only)") + .help(AppText.value( + "AAC is compressed, PCM is uncompressed lossless (MOV only)", + "AAC 为压缩格式,PCM 为未压缩无损格式(仅 MOV)", + language: language + )) } Section { - Text("Audio tracks are recorded separately for post-processing flexibility.") + Text(AppText.value( + "Audio tracks are recorded separately for post-processing flexibility.", + "音轨会被单独录制,便于后期处理。", + language: language + )) .font(.caption) .foregroundStyle(.secondary) } @@ -215,6 +269,7 @@ struct GeneralSettingsView: View { var updaterService: UpdaterService @State private var automaticallyChecksForUpdates: Bool + private var language: AppLanguage { settings.appLanguage } init(settings: SettingsStore, updaterService: UpdaterService) { self.settings = settings @@ -235,15 +290,23 @@ struct GeneralSettingsView: View { var body: some View { Form { - Section("Output Location") { + Section(AppText.value("Language", "语言", language: language)) { + Picker(AppText.value("Language", "语言", language: language), selection: $settings.appLanguage) { + ForEach(AppLanguage.allCases) { language in + Text(language.displayName).tag(language) + } + } + } + + Section(AppText.value("Output Location", "输出位置", language: language)) { LabeledContent { HStack { - Button("Change...") { + Button(AppText.value("Change...", "更改...", language: language)) { selectOutputDirectory() } if settings.hasCustomOutputDirectory { - Button("Reset", role: .destructive) { + Button(AppText.value("Reset", "重置", language: language), role: .destructive) { settings.resetOutputDirectory() } } @@ -259,21 +322,21 @@ struct GeneralSettingsView: View { } } - Section("Software Updates") { - Toggle("Automatically check for updates", isOn: $automaticallyChecksForUpdates) + Section(AppText.value("Software Updates", "软件更新", language: language)) { + Toggle(AppText.value("Automatically check for updates", "自动检查更新", language: language), isOn: $automaticallyChecksForUpdates) .onChange(of: automaticallyChecksForUpdates) { _, newValue in updaterService.automaticallyChecksForUpdates = newValue } - LabeledContent("Updates") { - Button("Check for Update") { + LabeledContent(AppText.value("Updates", "更新", language: language)) { + Button(AppText.value("Check for Update", "检查更新", language: language)) { updaterService.checkForUpdates() } .disabled(!updaterService.canCheckForUpdates) } } - AboutSection() + AboutSection(language: language) } .formStyle(.grouped) .padding() @@ -282,8 +345,8 @@ struct GeneralSettingsView: View { /// Opens an NSOpenPanel to select a custom output directory private func selectOutputDirectory() { let panel = NSOpenPanel() - panel.title = "Select Output Directory" - panel.message = "Choose where recordings will be saved" + panel.title = AppText.value("Select Output Directory", "选择输出目录", language: language) + panel.message = AppText.value("Choose where recordings will be saved", "选择录制文件的保存位置", language: language) panel.canChooseFiles = false panel.canChooseDirectories = true panel.canCreateDirectories = true @@ -299,8 +362,11 @@ struct GeneralSettingsView: View { // MARK: - About Section struct AboutSection: View { + let language: AppLanguage + private var appVersion: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + ?? AppText.value("Unknown", "未知", language: language) } private var gitSHA: String { @@ -308,14 +374,14 @@ struct AboutSection: View { } var body: some View { - Section("About") { - LabeledContent("Version", value: "v\(appVersion) (\(gitSHA))") + Section(AppText.value("About", "关于", language: language)) { + LabeledContent(AppText.value("Version", "版本", language: language), value: "v\(appVersion) (\(gitSHA))") - LabeledContent("Website") { + LabeledContent(AppText.value("Website", "网站", language: language)) { Link("jsattler.github.io/BetterCapture", destination: URL(string: "https://jsattler.github.io/BetterCapture")!) } - LabeledContent("Source Code") { + LabeledContent(AppText.value("Source Code", "源代码", language: language)) { Link("github.com/jsattler/BetterCapture", destination: URL(string: "https://github.com/jsattler/BetterCapture")!) } } diff --git a/BetterCaptureTests/ErrorTests.swift b/BetterCaptureTests/ErrorTests.swift index 44633e4..c05e06f 100644 --- a/BetterCaptureTests/ErrorTests.swift +++ b/BetterCaptureTests/ErrorTests.swift @@ -60,11 +60,19 @@ struct ErrorTests { @Test func captureErrorScreenRecordingMentionsPermission() { let error = CaptureError.screenRecordingPermissionDenied - #expect(error.errorDescription?.localizedStandardContains("permission") == true) + let description = error.errorDescription + #expect( + description?.localizedStandardContains("permission") == true || + description?.localizedStandardContains("权限") == true + ) } @Test func captureErrorMicrophoneMentionsPermission() { let error = CaptureError.microphonePermissionDenied - #expect(error.errorDescription?.localizedStandardContains("permission") == true) + let description = error.errorDescription + #expect( + description?.localizedStandardContains("permission") == true || + description?.localizedStandardContains("权限") == true + ) } } diff --git a/BetterCaptureTests/FrameRateTests.swift b/BetterCaptureTests/FrameRateTests.swift index 5f2b1d2..2fdc2d4 100644 --- a/BetterCaptureTests/FrameRateTests.swift +++ b/BetterCaptureTests/FrameRateTests.swift @@ -14,6 +14,7 @@ struct FrameRateTests { @Test func displayNameNative() { #expect(FrameRate.native.displayName == "Native") + #expect(FrameRate.native.displayName(language: .simplifiedChinese) == "原生") } @Test func displayNameExplicitRates() { diff --git a/BetterCaptureTests/SettingsStoreTests.swift b/BetterCaptureTests/SettingsStoreTests.swift index 1b9c487..a5b916b 100644 --- a/BetterCaptureTests/SettingsStoreTests.swift +++ b/BetterCaptureTests/SettingsStoreTests.swift @@ -79,6 +79,17 @@ struct SettingsStoreTests { #expect(store.showBetterCapture == false) } + @Test func defaultAppLanguageIsEnglish() { + let store = makeStore() + #expect(store.appLanguage == .english) + } + + @Test func appLanguagePersists() { + let store = makeStore() + store.appLanguage = .simplifiedChinese + #expect(store.appLanguage == .simplifiedChinese) + } + // MARK: - Codec/Container Compatibility @Test func settingProResToMP4SwitchesContainerToMOV() { diff --git a/script/package_dmg.sh b/script/package_dmg.sh new file mode 100755 index 0000000..0494dab --- /dev/null +++ b/script/package_dmg.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_NAME="BetterCapture" +CONFIGURATION="${CONFIGURATION:-Debug}" +CODE_SIGNING_ALLOWED="${CODE_SIGNING_ALLOWED:-NO}" + +DIST_DIR="$ROOT_DIR/dist" +BUILD_DIR="$DIST_DIR/build" +STAGING_DIR="$DIST_DIR/dmg-staging" +DMG_PATH="$DIST_DIR/$APP_NAME.dmg" + +mkdir -p "$DIST_DIR" + +xcodebuild build \ + -project "$ROOT_DIR/BetterCapture.xcodeproj" \ + -scheme "$APP_NAME" \ + -destination "platform=macOS" \ + -configuration "$CONFIGURATION" \ + CODE_SIGNING_ALLOWED="$CODE_SIGNING_ALLOWED" \ + CONFIGURATION_BUILD_DIR="$BUILD_DIR" + +rm -rf "$STAGING_DIR" "$DMG_PATH" +mkdir -p "$STAGING_DIR" + +cp -R "$BUILD_DIR/$APP_NAME.app" "$STAGING_DIR/" +ln -s /Applications "$STAGING_DIR/Applications" + +hdiutil create \ + -volname "$APP_NAME" \ + -srcfolder "$STAGING_DIR" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +echo "Created $DMG_PATH"