New low-level recorder targeting devices directly. Includes device switching during recording, enhanced logging (transport type, format, buffer), and log export feature.
575 lines
25 KiB
Swift
575 lines
25 KiB
Swift
import SwiftUI
|
|
import Cocoa
|
|
import KeyboardShortcuts
|
|
import LaunchAtLogin
|
|
import AVFoundation
|
|
|
|
struct SettingsView: View {
|
|
@EnvironmentObject private var updaterViewModel: UpdaterViewModel
|
|
@EnvironmentObject private var menuBarManager: MenuBarManager
|
|
@EnvironmentObject private var hotkeyManager: HotkeyManager
|
|
@EnvironmentObject private var whisperState: WhisperState
|
|
@EnvironmentObject private var enhancementService: AIEnhancementService
|
|
@StateObject private var deviceManager = AudioDeviceManager.shared
|
|
@ObservedObject private var soundManager = SoundManager.shared
|
|
@ObservedObject private var mediaController = MediaController.shared
|
|
@ObservedObject private var playbackController = PlaybackController.shared
|
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true
|
|
@AppStorage("autoUpdateCheck") private var autoUpdateCheck = true
|
|
@AppStorage("enableAnnouncements") private var enableAnnouncements = true
|
|
@AppStorage("restoreClipboardAfterPaste") private var restoreClipboardAfterPaste = false
|
|
@AppStorage("clipboardRestoreDelay") private var clipboardRestoreDelay = 2.0
|
|
@State private var showResetOnboardingAlert = false
|
|
@State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder)
|
|
@State private var isCustomCancelEnabled = false
|
|
@State private var expandedSections: Set<ExpandableSection> = []
|
|
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
SettingsSection(
|
|
icon: "command.circle",
|
|
title: "VoiceInk Shortcuts",
|
|
subtitle: "Choose how you want to trigger VoiceInk"
|
|
) {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
hotkeyView(
|
|
title: "Hotkey 1",
|
|
binding: $hotkeyManager.selectedHotkey1,
|
|
shortcutName: .toggleMiniRecorder
|
|
)
|
|
|
|
if hotkeyManager.selectedHotkey2 != .none {
|
|
Divider()
|
|
hotkeyView(
|
|
title: "Hotkey 2",
|
|
binding: $hotkeyManager.selectedHotkey2,
|
|
shortcutName: .toggleMiniRecorder2,
|
|
isRemovable: true,
|
|
onRemove: {
|
|
withAnimation { hotkeyManager.selectedHotkey2 = .none }
|
|
}
|
|
)
|
|
}
|
|
|
|
if hotkeyManager.selectedHotkey1 != .none && hotkeyManager.selectedHotkey2 == .none {
|
|
HStack {
|
|
Spacer()
|
|
Button(action: {
|
|
withAnimation { hotkeyManager.selectedHotkey2 = .rightOption }
|
|
}) {
|
|
Label("Add another hotkey", systemImage: "plus.circle.fill")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundColor(.accentColor)
|
|
}
|
|
}
|
|
|
|
Text("Quick tap to start hands-free recording (tap again to stop). Press and hold for push-to-talk (release to stop recording).")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
SettingsSection(
|
|
icon: "keyboard.badge.ellipsis",
|
|
title: "Other App Shortcuts",
|
|
subtitle: "Additional shortcuts for VoiceInk"
|
|
) {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
// Paste Last Transcript (Original)
|
|
HStack(spacing: 12) {
|
|
Text("Paste Last Transcript(Original)")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
KeyboardShortcuts.Recorder(for: .pasteLastTranscription)
|
|
.controlSize(.small)
|
|
|
|
InfoTip(
|
|
title: "Paste Last Transcript(Original)",
|
|
message: "Shortcut for pasting the most recent transcription."
|
|
)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
// Paste Last Transcript (Enhanced)
|
|
HStack(spacing: 12) {
|
|
Text("Paste Last Transcript(Enhanced)")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
KeyboardShortcuts.Recorder(for: .pasteLastEnhancement)
|
|
.controlSize(.small)
|
|
|
|
InfoTip(
|
|
title: "Paste Last Transcript(Enhanced)",
|
|
message: "Pastes the enhanced transcript if available, otherwise falls back to the original."
|
|
)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
|
|
|
|
// Retry Last Transcription
|
|
HStack(spacing: 12) {
|
|
Text("Retry Last Transcription")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
KeyboardShortcuts.Recorder(for: .retryLastTranscription)
|
|
.controlSize(.small)
|
|
|
|
InfoTip(
|
|
title: "Retry Last Transcription",
|
|
message: "Re-transcribe the last recorded audio using the current model and copy the result."
|
|
)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
Divider()
|
|
|
|
|
|
|
|
ExpandableToggleSection(
|
|
section: .customCancel,
|
|
title: "Custom Cancel Shortcut",
|
|
helpText: "Shortcut for cancelling the current recording session. Default: double-tap Escape.",
|
|
isEnabled: $isCustomCancelEnabled,
|
|
expandedSections: $expandedSections
|
|
) {
|
|
HStack(spacing: 12) {
|
|
Text("Cancel Shortcut")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
KeyboardShortcuts.Recorder(for: .cancelRecorder)
|
|
.controlSize(.small)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
.onChange(of: isCustomCancelEnabled) { _, newValue in
|
|
if !newValue {
|
|
KeyboardShortcuts.setShortcut(nil, for: .cancelRecorder)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
ExpandableToggleSection(
|
|
section: .middleClick,
|
|
title: "Enable Middle-Click Toggle",
|
|
helpText: "Use middle mouse button to toggle VoiceInk recording.",
|
|
isEnabled: $hotkeyManager.isMiddleClickToggleEnabled,
|
|
expandedSections: $expandedSections
|
|
) {
|
|
HStack(spacing: 8) {
|
|
Text("Activation Delay")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
TextField("", value: $hotkeyManager.middleClickActivationDelay, formatter: {
|
|
let formatter = NumberFormatter()
|
|
formatter.numberStyle = .none
|
|
formatter.minimum = 0
|
|
return formatter
|
|
}())
|
|
.textFieldStyle(PlainTextFieldStyle())
|
|
.padding(EdgeInsets(top: 3, leading: 6, bottom: 3, trailing: 6))
|
|
.background(Color(NSColor.textBackgroundColor))
|
|
.cornerRadius(5)
|
|
.frame(width: 70)
|
|
|
|
Text("ms")
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsSection(
|
|
icon: "speaker.wave.2.bubble.left.fill",
|
|
title: "Recording Feedback",
|
|
subtitle: "Customize app & system feedback"
|
|
) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
ExpandableToggleSection(
|
|
section: .soundFeedback,
|
|
title: "Sound feedback",
|
|
helpText: "Play sounds when recording starts and stops",
|
|
isEnabled: $soundManager.isEnabled,
|
|
expandedSections: $expandedSections
|
|
) {
|
|
CustomSoundSettingsView()
|
|
}
|
|
|
|
Divider()
|
|
|
|
ExpandableToggleSection(
|
|
section: .systemMute,
|
|
title: "Mute system audio during recording",
|
|
helpText: "Automatically mute system audio when recording starts and restore when recording stops",
|
|
isEnabled: $mediaController.isSystemMuteEnabled,
|
|
expandedSections: $expandedSections
|
|
) {
|
|
HStack(spacing: 8) {
|
|
Text("Resume Delay")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
Picker("", selection: $mediaController.audioResumptionDelay) {
|
|
Text("0s").tag(0.0)
|
|
Text("1s").tag(1.0)
|
|
Text("2s").tag(2.0)
|
|
Text("3s").tag(3.0)
|
|
Text("4s").tag(4.0)
|
|
Text("5s").tag(5.0)
|
|
}
|
|
.pickerStyle(.menu)
|
|
.frame(width: 80)
|
|
|
|
InfoTip(
|
|
title: "Audio Resume Delay",
|
|
message: "Delay before unmuting system audio after recording stops. Useful for Bluetooth headphones that need time to switch from microphone mode back to high-quality audio mode. Recommended: 2s for AirPods/Bluetooth headphones, 0s for wired headphones."
|
|
)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
ExpandableToggleSection(
|
|
section: .clipboardRestore,
|
|
title: "Restore clipboard after paste",
|
|
helpText: "When enabled, VoiceInk will restore your original clipboard content after pasting the transcription.",
|
|
isEnabled: $restoreClipboardAfterPaste,
|
|
expandedSections: $expandedSections
|
|
) {
|
|
HStack(spacing: 8) {
|
|
Text("Restore Delay")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
Picker("", selection: $clipboardRestoreDelay) {
|
|
Text("1s").tag(1.0)
|
|
Text("2s").tag(2.0)
|
|
Text("3s").tag(3.0)
|
|
Text("4s").tag(4.0)
|
|
Text("5s").tag(5.0)
|
|
}
|
|
.pickerStyle(.menu)
|
|
.frame(width: 80)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
PowerModeSettingsSection()
|
|
|
|
ExperimentalFeaturesSection()
|
|
|
|
SettingsSection(
|
|
icon: "rectangle.on.rectangle",
|
|
title: "Recorder Style",
|
|
subtitle: "Choose your preferred recorder interface"
|
|
) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Select how you want the recorder to appear on your screen.")
|
|
.settingsDescription()
|
|
|
|
Picker("Recorder Style", selection: $whisperState.recorderType) {
|
|
Text("Notch Recorder").tag("notch")
|
|
Text("Mini Recorder").tag("mini")
|
|
}
|
|
.pickerStyle(.radioGroup)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
SettingsSection(
|
|
icon: "doc.on.clipboard",
|
|
title: "Paste Method",
|
|
subtitle: "Choose how text is pasted"
|
|
) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Select the method used to paste text. Use AppleScript if you have a non-standard keyboard layout.")
|
|
.settingsDescription()
|
|
|
|
Toggle("Use AppleScript Paste Method", isOn: Binding(
|
|
get: { UserDefaults.standard.bool(forKey: "UseAppleScriptPaste") },
|
|
set: { UserDefaults.standard.set($0, forKey: "UseAppleScriptPaste") }
|
|
))
|
|
.toggleStyle(.switch)
|
|
}
|
|
}
|
|
|
|
SettingsSection(
|
|
icon: "gear",
|
|
title: "General",
|
|
subtitle: "Appearance, startup, and updates"
|
|
) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Toggle("Hide Dock Icon (Menu Bar Only)", isOn: $menuBarManager.isMenuBarOnly)
|
|
.toggleStyle(.switch)
|
|
|
|
LaunchAtLogin.Toggle()
|
|
.toggleStyle(.switch)
|
|
|
|
Toggle("Enable automatic update checks", isOn: $autoUpdateCheck)
|
|
.toggleStyle(.switch)
|
|
.onChange(of: autoUpdateCheck) { _, newValue in
|
|
updaterViewModel.toggleAutoUpdates(newValue)
|
|
}
|
|
|
|
Toggle("Show app announcements", isOn: $enableAnnouncements)
|
|
.toggleStyle(.switch)
|
|
.onChange(of: enableAnnouncements) { _, newValue in
|
|
if newValue {
|
|
AnnouncementsService.shared.start()
|
|
} else {
|
|
AnnouncementsService.shared.stop()
|
|
}
|
|
}
|
|
|
|
Button("Check for Updates Now") {
|
|
updaterViewModel.checkForUpdates()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.large)
|
|
.disabled(!updaterViewModel.canCheckForUpdates)
|
|
|
|
Divider()
|
|
|
|
Button("Reset Onboarding") {
|
|
showResetOnboardingAlert = true
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.large)
|
|
}
|
|
}
|
|
|
|
SettingsSection(
|
|
icon: "lock.shield",
|
|
title: "Data & Privacy",
|
|
subtitle: "Control transcript history and storage"
|
|
) {
|
|
AudioCleanupSettingsView()
|
|
}
|
|
|
|
SettingsSection(
|
|
icon: "arrow.up.arrow.down.circle",
|
|
title: "Data Management",
|
|
subtitle: "Import or export your settings"
|
|
) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Export your custom prompts, power modes, word replacements, keyboard shortcuts, and app preferences to a backup file. API keys are not included in the export.")
|
|
.settingsDescription()
|
|
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
ImportExportService.shared.importSettings(
|
|
enhancementService: enhancementService,
|
|
whisperPrompt: whisperState.whisperPrompt,
|
|
hotkeyManager: hotkeyManager,
|
|
menuBarManager: menuBarManager,
|
|
mediaController: MediaController.shared,
|
|
playbackController: PlaybackController.shared,
|
|
soundManager: SoundManager.shared,
|
|
whisperState: whisperState
|
|
)
|
|
} label: {
|
|
Label("Import Settings...", systemImage: "arrow.down.doc")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.controlSize(.large)
|
|
|
|
Button {
|
|
ImportExportService.shared.exportSettings(
|
|
enhancementService: enhancementService,
|
|
whisperPrompt: whisperState.whisperPrompt,
|
|
hotkeyManager: hotkeyManager,
|
|
menuBarManager: menuBarManager,
|
|
mediaController: MediaController.shared,
|
|
playbackController: PlaybackController.shared,
|
|
soundManager: SoundManager.shared,
|
|
whisperState: whisperState
|
|
)
|
|
} label: {
|
|
Label("Export Settings...", systemImage: "arrow.up.doc")
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.controlSize(.large)
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsSection(
|
|
icon: "ant.circle",
|
|
title: "Diagnostics",
|
|
subtitle: "Export logs for troubleshooting"
|
|
) {
|
|
DiagnosticsSettingsView()
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.onAppear {
|
|
isCustomCancelEnabled = KeyboardShortcuts.getShortcut(for: .cancelRecorder) != nil
|
|
}
|
|
.alert("Reset Onboarding", isPresented: $showResetOnboardingAlert) {
|
|
Button("Cancel", role: .cancel) { }
|
|
Button("Reset", role: .destructive) {
|
|
// Defer state change to avoid layout issues while alert dismisses
|
|
DispatchQueue.main.async {
|
|
hasCompletedOnboarding = false
|
|
}
|
|
}
|
|
} message: {
|
|
Text("Are you sure you want to reset the onboarding? You'll see the introduction screens again the next time you launch the app.")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func hotkeyView(
|
|
title: String,
|
|
binding: Binding<HotkeyManager.HotkeyOption>,
|
|
shortcutName: KeyboardShortcuts.Name,
|
|
isRemovable: Bool = false,
|
|
onRemove: (() -> Void)? = nil
|
|
) -> some View {
|
|
HStack(spacing: 12) {
|
|
Text(title)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
Menu {
|
|
ForEach(HotkeyManager.HotkeyOption.allCases, id: \.self) { option in
|
|
Button(action: {
|
|
binding.wrappedValue = option
|
|
}) {
|
|
HStack {
|
|
Text(option.displayName)
|
|
if binding.wrappedValue == option {
|
|
Spacer()
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Text(binding.wrappedValue.displayName)
|
|
.foregroundColor(.primary)
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(6)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
|
|
if binding.wrappedValue == .custom {
|
|
KeyboardShortcuts.Recorder(for: shortcutName)
|
|
.controlSize(.small)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isRemovable {
|
|
Button(action: {
|
|
onRemove?()
|
|
}) {
|
|
Image(systemName: "minus.circle.fill")
|
|
.foregroundColor(.red)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SettingsSection<Content: View>: View {
|
|
let icon: String
|
|
let title: String
|
|
let subtitle: String
|
|
let content: Content
|
|
var showWarning: Bool = false
|
|
|
|
init(icon: String, title: String, subtitle: String, showWarning: Bool = false, @ViewBuilder content: () -> Content) {
|
|
self.icon = icon
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.showWarning = showWarning
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 20))
|
|
.foregroundColor(showWarning ? .red : .accentColor)
|
|
.frame(width: 24, height: 24)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.headline)
|
|
Text(subtitle)
|
|
.font(.subheadline)
|
|
.foregroundColor(showWarning ? .red : .secondary)
|
|
}
|
|
|
|
if showWarning {
|
|
Spacer()
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.red)
|
|
.help("Permission required for VoiceInk to function properly")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.padding(.vertical, 4)
|
|
|
|
content
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(CardBackground(isSelected: showWarning, useAccentGradientWhenSelected: true))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(showWarning ? Color.red.opacity(0.5) : Color.clear, lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Add this extension for consistent description text styling
|
|
extension Text {
|
|
func settingsDescription() -> some View {
|
|
self
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|