Refactor: Unify and improve hotkey settings UI
This commit is contained in:
parent
888cc5125b
commit
3e609d1e3b
@ -5,57 +5,62 @@ import AppKit
|
||||
|
||||
extension KeyboardShortcuts.Name {
|
||||
static let toggleMiniRecorder = Self("toggleMiniRecorder")
|
||||
static let toggleMiniRecorder2 = Self("toggleMiniRecorder2")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class HotkeyManager: ObservableObject {
|
||||
@Published var isListening = false
|
||||
@Published var isShortcutConfigured = false
|
||||
@Published var isPushToTalkEnabled: Bool {
|
||||
@Published var selectedHotkey1: HotkeyOption {
|
||||
didSet {
|
||||
UserDefaults.standard.set(isPushToTalkEnabled, forKey: "isPushToTalkEnabled")
|
||||
resetKeyStates()
|
||||
setupKeyMonitor()
|
||||
UserDefaults.standard.set(selectedHotkey1.rawValue, forKey: "selectedHotkey1")
|
||||
setupHotkeyMonitoring()
|
||||
}
|
||||
}
|
||||
@Published var pushToTalkKey: PushToTalkKey {
|
||||
@Published var selectedHotkey2: HotkeyOption {
|
||||
didSet {
|
||||
UserDefaults.standard.set(pushToTalkKey.rawValue, forKey: "pushToTalkKey")
|
||||
resetKeyStates()
|
||||
UserDefaults.standard.set(selectedHotkey2.rawValue, forKey: "selectedHotkey2")
|
||||
setupHotkeyMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
private var whisperState: WhisperState
|
||||
private var currentKeyState = false
|
||||
private var miniRecorderShortcutManager: MiniRecorderShortcutManager
|
||||
|
||||
// Change from single monitor to separate local and global monitors
|
||||
// NSEvent monitoring for modifier keys
|
||||
private var globalEventMonitor: Any?
|
||||
private var localEventMonitor: Any?
|
||||
|
||||
// Key handling properties
|
||||
// Key state tracking
|
||||
private var currentKeyState = false
|
||||
private var keyPressStartTime: Date?
|
||||
private let briefPressThreshold = 1.0 // 1 second threshold for brief press
|
||||
private var isHandsFreeMode = false // Track if we're in hands-free recording mode
|
||||
|
||||
// Add cooldown management
|
||||
private var lastShortcutTriggerTime: Date?
|
||||
private let shortcutCooldownInterval: TimeInterval = 0.5 // 500ms cooldown
|
||||
private var isHandsFreeMode = false
|
||||
|
||||
// Debounce for Fn key
|
||||
private var fnDebounceTask: Task<Void, Never>?
|
||||
private var pendingFnKeyState: Bool? = nil
|
||||
|
||||
enum PushToTalkKey: String, CaseIterable {
|
||||
// Keyboard shortcut state tracking
|
||||
private var shortcutKeyPressStartTime: Date?
|
||||
private var isShortcutHandsFreeMode = false
|
||||
private var shortcutCurrentKeyState = false
|
||||
private var lastShortcutTriggerTime: Date?
|
||||
private let shortcutCooldownInterval: TimeInterval = 0.5
|
||||
|
||||
enum HotkeyOption: String, CaseIterable {
|
||||
case none = "none"
|
||||
case rightOption = "rightOption"
|
||||
case leftOption = "leftOption"
|
||||
case leftControl = "leftControl"
|
||||
case leftControl = "leftControl"
|
||||
case rightControl = "rightControl"
|
||||
case fn = "fn"
|
||||
case rightCommand = "rightCommand"
|
||||
case rightShift = "rightShift"
|
||||
case custom = "custom"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .none: return "None"
|
||||
case .rightOption: return "Right Option (⌥)"
|
||||
case .leftOption: return "Left Option (⌥)"
|
||||
case .leftControl: return "Left Control (⌃)"
|
||||
@ -63,10 +68,11 @@ class HotkeyManager: ObservableObject {
|
||||
case .fn: return "Fn"
|
||||
case .rightCommand: return "Right Command (⌘)"
|
||||
case .rightShift: return "Right Shift (⇧)"
|
||||
case .custom: return "Custom"
|
||||
}
|
||||
}
|
||||
|
||||
var keyCode: CGKeyCode {
|
||||
var keyCode: CGKeyCode? {
|
||||
switch self {
|
||||
case .rightOption: return 0x3D
|
||||
case .leftOption: return 0x3A
|
||||
@ -75,52 +81,91 @@ class HotkeyManager: ObservableObject {
|
||||
case .fn: return 0x3F
|
||||
case .rightCommand: return 0x36
|
||||
case .rightShift: return 0x3C
|
||||
case .custom, .none: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var isModifierKey: Bool {
|
||||
return self != .custom && self != .none
|
||||
}
|
||||
}
|
||||
|
||||
init(whisperState: WhisperState) {
|
||||
self.isPushToTalkEnabled = UserDefaults.standard.bool(forKey: "isPushToTalkEnabled")
|
||||
self.pushToTalkKey = PushToTalkKey(rawValue: UserDefaults.standard.string(forKey: "pushToTalkKey") ?? "") ?? .rightCommand
|
||||
// One-time migration from legacy single-hotkey settings
|
||||
if UserDefaults.standard.object(forKey: "didMigrateHotkeys_v2") == nil {
|
||||
// If legacy push-to-talk modifier key was enabled, carry it over
|
||||
if UserDefaults.standard.bool(forKey: "isPushToTalkEnabled"),
|
||||
let legacyRaw = UserDefaults.standard.string(forKey: "pushToTalkKey"),
|
||||
let legacyKey = HotkeyOption(rawValue: legacyRaw) {
|
||||
UserDefaults.standard.set(legacyKey.rawValue, forKey: "selectedHotkey1")
|
||||
}
|
||||
// If a custom shortcut existed, mark hotkey-1 as custom (shortcut itself already persisted)
|
||||
if KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil {
|
||||
UserDefaults.standard.set(HotkeyOption.custom.rawValue, forKey: "selectedHotkey1")
|
||||
}
|
||||
// Leave second hotkey as .none
|
||||
UserDefaults.standard.set(true, forKey: "didMigrateHotkeys_v2")
|
||||
}
|
||||
// ---- normal initialisation ----
|
||||
self.selectedHotkey1 = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "selectedHotkey1") ?? "") ?? .rightCommand
|
||||
self.selectedHotkey2 = HotkeyOption(rawValue: UserDefaults.standard.string(forKey: "selectedHotkey2") ?? "") ?? .none
|
||||
self.whisperState = whisperState
|
||||
self.miniRecorderShortcutManager = MiniRecorderShortcutManager(whisperState: whisperState)
|
||||
|
||||
updateShortcutStatus()
|
||||
}
|
||||
|
||||
private func resetKeyStates() {
|
||||
currentKeyState = false
|
||||
keyPressStartTime = nil
|
||||
isHandsFreeMode = false
|
||||
func startHotkeyMonitoring() {
|
||||
setupHotkeyMonitoring()
|
||||
}
|
||||
|
||||
private func setupKeyMonitor() {
|
||||
removeKeyMonitor()
|
||||
private func setupHotkeyMonitoring() {
|
||||
removeAllMonitoring()
|
||||
|
||||
guard isPushToTalkEnabled else { return }
|
||||
|
||||
// Global monitor for capturing flags when app is in background
|
||||
setupModifierKeyMonitoring()
|
||||
setupCustomShortcutMonitoring()
|
||||
}
|
||||
|
||||
private func setupModifierKeyMonitoring() {
|
||||
// Only set up if at least one hotkey is a modifier key
|
||||
guard (selectedHotkey1.isModifierKey && selectedHotkey1 != .none) || (selectedHotkey2.isModifierKey && selectedHotkey2 != .none) else { return }
|
||||
|
||||
globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
await self.handleNSKeyEvent(event)
|
||||
await self.handleModifierKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Local monitor for capturing flags when app has focus
|
||||
localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
guard let self = self else { return event }
|
||||
|
||||
Task { @MainActor in
|
||||
await self.handleNSKeyEvent(event)
|
||||
await self.handleModifierKeyEvent(event)
|
||||
}
|
||||
|
||||
return event // Return the event to allow normal processing
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
private func removeKeyMonitor() {
|
||||
private func setupCustomShortcutMonitoring() {
|
||||
// Hotkey 1
|
||||
if selectedHotkey1 == .custom {
|
||||
KeyboardShortcuts.onKeyDown(for: .toggleMiniRecorder) { [weak self] in
|
||||
Task { @MainActor in await self?.handleCustomShortcutKeyDown() }
|
||||
}
|
||||
KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder) { [weak self] in
|
||||
Task { @MainActor in await self?.handleCustomShortcutKeyUp() }
|
||||
}
|
||||
}
|
||||
// Hotkey 2
|
||||
if selectedHotkey2 == .custom {
|
||||
KeyboardShortcuts.onKeyDown(for: .toggleMiniRecorder2) { [weak self] in
|
||||
Task { @MainActor in await self?.handleCustomShortcutKeyDown() }
|
||||
}
|
||||
KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder2) { [weak self] in
|
||||
Task { @MainActor in await self?.handleCustomShortcutKeyUp() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAllMonitoring() {
|
||||
if let monitor = globalEventMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
globalEventMonitor = nil
|
||||
@ -130,87 +175,92 @@ class HotkeyManager: ObservableObject {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
localEventMonitor = nil
|
||||
}
|
||||
|
||||
resetKeyStates()
|
||||
}
|
||||
|
||||
private func handleNSKeyEvent(_ event: NSEvent) async {
|
||||
private func resetKeyStates() {
|
||||
currentKeyState = false
|
||||
keyPressStartTime = nil
|
||||
isHandsFreeMode = false
|
||||
shortcutCurrentKeyState = false
|
||||
shortcutKeyPressStartTime = nil
|
||||
isShortcutHandsFreeMode = false
|
||||
}
|
||||
|
||||
private func handleModifierKeyEvent(_ event: NSEvent) async {
|
||||
let keycode = event.keyCode
|
||||
let flags = event.modifierFlags
|
||||
|
||||
// Check if the target key is pressed based on the modifier flags
|
||||
var isKeyPressed = false
|
||||
var isTargetKey = false
|
||||
|
||||
switch pushToTalkKey {
|
||||
case .rightOption, .leftOption:
|
||||
isKeyPressed = flags.contains(.option)
|
||||
isTargetKey = keycode == pushToTalkKey.keyCode
|
||||
case .leftControl, .rightControl:
|
||||
isKeyPressed = flags.contains(.control)
|
||||
isTargetKey = keycode == pushToTalkKey.keyCode
|
||||
case .fn:
|
||||
isKeyPressed = flags.contains(.function)
|
||||
isTargetKey = keycode == pushToTalkKey.keyCode
|
||||
// Debounce only for Fn key
|
||||
if isTargetKey {
|
||||
pendingFnKeyState = isKeyPressed
|
||||
fnDebounceTask?.cancel()
|
||||
fnDebounceTask = Task { [pendingState = isKeyPressed] in
|
||||
try? await Task.sleep(nanoseconds: 75_000_000) // 75ms
|
||||
// Only act if the state hasn't changed during debounce
|
||||
if pendingFnKeyState == pendingState {
|
||||
await MainActor.run {
|
||||
self.processPushToTalkKey(isKeyPressed: pendingState)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
case .rightCommand:
|
||||
isKeyPressed = flags.contains(.command)
|
||||
isTargetKey = keycode == pushToTalkKey.keyCode
|
||||
case .rightShift:
|
||||
isKeyPressed = flags.contains(.shift)
|
||||
isTargetKey = keycode == pushToTalkKey.keyCode
|
||||
// Determine which hotkey (if any) is being triggered
|
||||
let activeHotkey: HotkeyOption?
|
||||
if selectedHotkey1.isModifierKey && selectedHotkey1.keyCode == keycode {
|
||||
activeHotkey = selectedHotkey1
|
||||
} else if selectedHotkey2.isModifierKey && selectedHotkey2.keyCode == keycode {
|
||||
activeHotkey = selectedHotkey2
|
||||
} else {
|
||||
activeHotkey = nil
|
||||
}
|
||||
|
||||
guard isTargetKey else { return }
|
||||
processPushToTalkKey(isKeyPressed: isKeyPressed)
|
||||
guard let hotkey = activeHotkey else { return }
|
||||
|
||||
var isKeyPressed = false
|
||||
|
||||
switch hotkey {
|
||||
case .rightOption, .leftOption:
|
||||
isKeyPressed = flags.contains(.option)
|
||||
case .leftControl, .rightControl:
|
||||
isKeyPressed = flags.contains(.control)
|
||||
case .fn:
|
||||
isKeyPressed = flags.contains(.function)
|
||||
// Debounce Fn key
|
||||
pendingFnKeyState = isKeyPressed
|
||||
fnDebounceTask?.cancel()
|
||||
fnDebounceTask = Task { [pendingState = isKeyPressed] in
|
||||
try? await Task.sleep(nanoseconds: 75_000_000) // 75ms
|
||||
if pendingFnKeyState == pendingState {
|
||||
await MainActor.run {
|
||||
self.processKeyPress(isKeyPressed: pendingState)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
case .rightCommand:
|
||||
isKeyPressed = flags.contains(.command)
|
||||
case .rightShift:
|
||||
isKeyPressed = flags.contains(.shift)
|
||||
case .custom, .none:
|
||||
return // Should not reach here
|
||||
}
|
||||
|
||||
processKeyPress(isKeyPressed: isKeyPressed)
|
||||
}
|
||||
|
||||
private func processPushToTalkKey(isKeyPressed: Bool) {
|
||||
private func processKeyPress(isKeyPressed: Bool) {
|
||||
guard isKeyPressed != currentKeyState else { return }
|
||||
currentKeyState = isKeyPressed
|
||||
|
||||
// Key is pressed down
|
||||
if isKeyPressed {
|
||||
keyPressStartTime = Date()
|
||||
|
||||
// If we're in hands-free mode, stop recording
|
||||
if isHandsFreeMode {
|
||||
isHandsFreeMode = false
|
||||
Task { @MainActor in await whisperState.handleToggleMiniRecorder() }
|
||||
return
|
||||
}
|
||||
|
||||
// Show recorder if not already visible
|
||||
if !whisperState.isMiniRecorderVisible {
|
||||
Task { @MainActor in await whisperState.handleToggleMiniRecorder() }
|
||||
}
|
||||
}
|
||||
// Key is released
|
||||
else {
|
||||
} else {
|
||||
let now = Date()
|
||||
|
||||
// Calculate press duration
|
||||
if let startTime = keyPressStartTime {
|
||||
let pressDuration = now.timeIntervalSince(startTime)
|
||||
|
||||
if pressDuration < briefPressThreshold {
|
||||
// For brief presses, enter hands-free mode
|
||||
isHandsFreeMode = true
|
||||
// Continue recording - do nothing on release
|
||||
} else {
|
||||
// For longer presses, stop and transcribe
|
||||
Task { @MainActor in await whisperState.handleToggleMiniRecorder() }
|
||||
}
|
||||
}
|
||||
@ -219,41 +269,66 @@ class HotkeyManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateShortcutStatus() {
|
||||
isShortcutConfigured = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil
|
||||
if isShortcutConfigured {
|
||||
setupShortcutHandler()
|
||||
setupKeyMonitor()
|
||||
} else {
|
||||
removeKeyMonitor()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupShortcutHandler() {
|
||||
KeyboardShortcuts.onKeyUp(for: .toggleMiniRecorder) { [weak self] in
|
||||
Task { @MainActor in
|
||||
await self?.handleShortcutTriggered()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleShortcutTriggered() async {
|
||||
// Check cooldown
|
||||
private func handleCustomShortcutKeyDown() async {
|
||||
if let lastTrigger = lastShortcutTriggerTime,
|
||||
Date().timeIntervalSince(lastTrigger) < shortcutCooldownInterval {
|
||||
return // Still in cooldown period
|
||||
return
|
||||
}
|
||||
|
||||
// Update last trigger time
|
||||
guard !shortcutCurrentKeyState else { return }
|
||||
shortcutCurrentKeyState = true
|
||||
lastShortcutTriggerTime = Date()
|
||||
shortcutKeyPressStartTime = Date()
|
||||
|
||||
// Handle the shortcut
|
||||
await whisperState.handleToggleMiniRecorder()
|
||||
if isShortcutHandsFreeMode {
|
||||
isShortcutHandsFreeMode = false
|
||||
await whisperState.handleToggleMiniRecorder()
|
||||
return
|
||||
}
|
||||
|
||||
if !whisperState.isMiniRecorderVisible {
|
||||
await whisperState.handleToggleMiniRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCustomShortcutKeyUp() async {
|
||||
guard shortcutCurrentKeyState else { return }
|
||||
shortcutCurrentKeyState = false
|
||||
|
||||
let now = Date()
|
||||
|
||||
if let startTime = shortcutKeyPressStartTime {
|
||||
let pressDuration = now.timeIntervalSince(startTime)
|
||||
|
||||
if pressDuration < briefPressThreshold {
|
||||
isShortcutHandsFreeMode = true
|
||||
} else {
|
||||
await whisperState.handleToggleMiniRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
shortcutKeyPressStartTime = nil
|
||||
}
|
||||
|
||||
// Computed property for backward compatibility with UI
|
||||
var isShortcutConfigured: Bool {
|
||||
let isHotkey1Configured = (selectedHotkey1 == .custom) ? (KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil) : true
|
||||
let isHotkey2Configured = (selectedHotkey2 == .custom) ? (KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder2) != nil) : true
|
||||
return isHotkey1Configured && isHotkey2Configured
|
||||
}
|
||||
|
||||
func updateShortcutStatus() {
|
||||
// Called when a custom shortcut changes
|
||||
if selectedHotkey1 == .custom || selectedHotkey2 == .custom {
|
||||
setupHotkeyMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
Task { @MainActor in
|
||||
removeKeyMonitor()
|
||||
removeAllMonitoring()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -6,8 +6,9 @@ import LaunchAtLogin
|
||||
|
||||
struct GeneralSettings: Codable {
|
||||
let toggleMiniRecorderShortcut: KeyboardShortcuts.Shortcut?
|
||||
let isPushToTalkEnabled: Bool?
|
||||
let pushToTalkKeyRawValue: String?
|
||||
let toggleMiniRecorderShortcut2: KeyboardShortcuts.Shortcut?
|
||||
let selectedHotkey1RawValue: String?
|
||||
let selectedHotkey2RawValue: String?
|
||||
let launchAtLoginEnabled: Bool?
|
||||
let isMenuBarOnly: Bool?
|
||||
let useAppleScriptPaste: Bool?
|
||||
@ -37,8 +38,7 @@ class ImportExportService {
|
||||
private let dictionaryItemsKey = "CustomDictionaryItems"
|
||||
private let wordReplacementsKey = "wordReplacements"
|
||||
|
||||
private let keyIsPushToTalkEnabled = "isPushToTalkEnabled"
|
||||
private let keyPushToTalkKey = "pushToTalkKey"
|
||||
|
||||
private let keyIsMenuBarOnly = "IsMenuBarOnly"
|
||||
private let keyUseAppleScriptPaste = "UseAppleScriptPaste"
|
||||
private let keyRecorderType = "RecorderType"
|
||||
@ -79,8 +79,9 @@ class ImportExportService {
|
||||
|
||||
let generalSettingsToExport = GeneralSettings(
|
||||
toggleMiniRecorderShortcut: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder),
|
||||
isPushToTalkEnabled: hotkeyManager.isPushToTalkEnabled,
|
||||
pushToTalkKeyRawValue: hotkeyManager.pushToTalkKey.rawValue,
|
||||
toggleMiniRecorderShortcut2: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder2),
|
||||
selectedHotkey1RawValue: hotkeyManager.selectedHotkey1.rawValue,
|
||||
selectedHotkey2RawValue: hotkeyManager.selectedHotkey2.rawValue,
|
||||
launchAtLoginEnabled: LaunchAtLogin.isEnabled,
|
||||
isMenuBarOnly: menuBarManager.isMenuBarOnly,
|
||||
useAppleScriptPaste: UserDefaults.standard.bool(forKey: keyUseAppleScriptPaste),
|
||||
@ -206,12 +207,16 @@ class ImportExportService {
|
||||
if let shortcut = general.toggleMiniRecorderShortcut {
|
||||
KeyboardShortcuts.setShortcut(shortcut, for: .toggleMiniRecorder)
|
||||
}
|
||||
if let pttEnabled = general.isPushToTalkEnabled {
|
||||
hotkeyManager.isPushToTalkEnabled = pttEnabled
|
||||
if let shortcut2 = general.toggleMiniRecorderShortcut2 {
|
||||
KeyboardShortcuts.setShortcut(shortcut2, for: .toggleMiniRecorder2)
|
||||
}
|
||||
if let pttKeyRaw = general.pushToTalkKeyRawValue,
|
||||
let pttKey = HotkeyManager.PushToTalkKey(rawValue: pttKeyRaw) {
|
||||
hotkeyManager.pushToTalkKey = pttKey
|
||||
if let hotkeyRaw = general.selectedHotkey1RawValue,
|
||||
let hotkey = HotkeyManager.HotkeyOption(rawValue: hotkeyRaw) {
|
||||
hotkeyManager.selectedHotkey1 = hotkey
|
||||
}
|
||||
if let hotkeyRaw2 = general.selectedHotkey2RawValue,
|
||||
let hotkey2 = HotkeyManager.HotkeyOption(rawValue: hotkeyRaw2) {
|
||||
hotkeyManager.selectedHotkey2 = hotkey2
|
||||
}
|
||||
if let launch = general.launchAtLoginEnabled {
|
||||
LaunchAtLogin.isEnabled = launch
|
||||
|
||||
@ -5,7 +5,6 @@ import KeyboardShortcuts
|
||||
// ViewType enum with all cases
|
||||
enum ViewType: String, CaseIterable {
|
||||
case metrics = "Dashboard"
|
||||
case record = "Record Audio"
|
||||
case transcribeAudio = "Transcribe Audio"
|
||||
case history = "History"
|
||||
case models = "AI Models"
|
||||
@ -20,7 +19,6 @@ enum ViewType: String, CaseIterable {
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .metrics: return "gauge.medium"
|
||||
case .record: return "mic.circle.fill"
|
||||
case .transcribeAudio: return "waveform.circle.fill"
|
||||
case .history: return "doc.text.fill"
|
||||
case .models: return "brain.head.profile"
|
||||
@ -193,6 +191,8 @@ struct ContentView: View {
|
||||
.frame(minWidth: 940, minHeight: 730)
|
||||
.onAppear {
|
||||
hasLoadedData = true
|
||||
// Initialize hotkey monitoring after the app is ready
|
||||
hotkeyManager.startHotkeyMonitoring()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToDestination)) { notification in
|
||||
print("ContentView: Received navigation notification")
|
||||
@ -235,13 +235,12 @@ struct ContentView: View {
|
||||
MetricsView(skipSetupCheck: true)
|
||||
} else {
|
||||
MetricsSetupView()
|
||||
.environmentObject(hotkeyManager)
|
||||
}
|
||||
case .models:
|
||||
ModelManagementView(whisperState: whisperState)
|
||||
case .enhancement:
|
||||
EnhancementSettingsView()
|
||||
case .record:
|
||||
RecordView()
|
||||
case .transcribeAudio:
|
||||
AudioTranscribeView()
|
||||
case .history:
|
||||
|
||||
@ -3,6 +3,7 @@ import KeyboardShortcuts
|
||||
|
||||
struct MetricsSetupView: View {
|
||||
@EnvironmentObject private var whisperState: WhisperState
|
||||
@EnvironmentObject private var hotkeyManager: HotkeyManager
|
||||
@State private var isAccessibilityEnabled = AXIsProcessTrusted()
|
||||
@State private var isScreenRecordingEnabled = CGPreflightScreenCaptureAccess()
|
||||
|
||||
@ -67,7 +68,7 @@ struct MetricsSetupView: View {
|
||||
switch index {
|
||||
case 0:
|
||||
stepInfo = (
|
||||
isCompleted: KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil,
|
||||
isCompleted: hotkeyManager.selectedHotkey1 != .none,
|
||||
icon: "command",
|
||||
title: "Set Keyboard Shortcut",
|
||||
description: "Use VoiceInk anywhere with a shortcut."
|
||||
@ -149,7 +150,7 @@ struct MetricsSetupView: View {
|
||||
openModelManagement()
|
||||
} else {
|
||||
// Handle different permission requests based on which one is missing
|
||||
if KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) == nil {
|
||||
if hotkeyManager.selectedHotkey1 == .none {
|
||||
openSettings()
|
||||
} else if !AXIsProcessTrusted() {
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||
@ -166,7 +167,7 @@ struct MetricsSetupView: View {
|
||||
}
|
||||
|
||||
private func getActionButtonTitle() -> String {
|
||||
if KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) == nil {
|
||||
if hotkeyManager.selectedHotkey1 == .none {
|
||||
return "Configure Shortcut"
|
||||
} else if !AXIsProcessTrusted() {
|
||||
return "Enable Accessibility"
|
||||
@ -185,7 +186,7 @@ struct MetricsSetupView: View {
|
||||
}
|
||||
|
||||
private var isShortcutAndAccessibilityGranted: Bool {
|
||||
KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil &&
|
||||
hotkeyManager.selectedHotkey1 != .none &&
|
||||
AXIsProcessTrusted() &&
|
||||
CGPreflightScreenCaptureAccess()
|
||||
}
|
||||
|
||||
@ -203,32 +203,13 @@ struct OnboardingPermissionsView: View {
|
||||
|
||||
// Keyboard shortcut recorder (only shown for keyboard shortcut step)
|
||||
if permissions[currentPermissionIndex].type == .keyboardShortcut {
|
||||
VStack(spacing: 16) {
|
||||
if hotkeyManager.isShortcutConfigured {
|
||||
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) {
|
||||
KeyboardShortcutView(shortcut: shortcut)
|
||||
.scaleEffect(1.2)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 16) {
|
||||
KeyboardShortcuts.Recorder("Set Shortcut:", name: .toggleMiniRecorder) { newShortcut in
|
||||
withAnimation {
|
||||
if newShortcut != nil {
|
||||
permissionStates[currentPermissionIndex] = true
|
||||
showAnimation = true
|
||||
} else {
|
||||
permissionStates[currentPermissionIndex] = false
|
||||
showAnimation = false
|
||||
}
|
||||
hotkeyManager.updateShortcutStatus()
|
||||
}
|
||||
}
|
||||
.controlSize(.large)
|
||||
|
||||
SkipButton(text: "Skip for now") {
|
||||
moveToNext()
|
||||
}
|
||||
hotkeyView(
|
||||
binding: $hotkeyManager.selectedHotkey1,
|
||||
shortcutName: .toggleMiniRecorder
|
||||
) { isConfigured in
|
||||
withAnimation {
|
||||
permissionStates[currentPermissionIndex] = isConfigured
|
||||
showAnimation = isConfigured
|
||||
}
|
||||
}
|
||||
.scaleEffect(scale)
|
||||
@ -424,4 +405,73 @@ struct OnboardingPermissionsView: View {
|
||||
return permissionStates[currentPermissionIndex] ? "Continue" : "Enable Access"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func hotkeyView(
|
||||
binding: Binding<HotkeyManager.HotkeyOption>,
|
||||
shortcutName: KeyboardShortcuts.Name,
|
||||
onConfigured: @escaping (Bool) -> Void
|
||||
) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack(spacing: 12) {
|
||||
Spacer()
|
||||
|
||||
Text("Shortcut:")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
|
||||
Menu {
|
||||
ForEach(HotkeyManager.HotkeyOption.allCases, id: \.self) { option in
|
||||
if option != .none && option != .custom { // Exclude 'None' and 'Custom' from the list
|
||||
Button(action: {
|
||||
binding.wrappedValue = option
|
||||
onConfigured(option.isModifierKey)
|
||||
}) {
|
||||
HStack {
|
||||
Text(option.displayName)
|
||||
if binding.wrappedValue == option {
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(binding.wrappedValue.displayName)
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.white.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if binding.wrappedValue == .custom {
|
||||
KeyboardShortcuts.Recorder(for: shortcutName) { newShortcut in
|
||||
onConfigured(newShortcut != nil)
|
||||
}
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.white.opacity(0.05))
|
||||
.cornerRadius(12)
|
||||
.onChange(of: binding.wrappedValue) { newValue in
|
||||
onConfigured(newValue != .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,13 +35,26 @@ struct OnboardingTutorialView: View {
|
||||
|
||||
// Keyboard shortcut display
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Your Shortcut")
|
||||
.font(.system(size: 28, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
HStack {
|
||||
Text("Your Shortcut")
|
||||
.font(.system(size: 28, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
|
||||
|
||||
}
|
||||
|
||||
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) {
|
||||
if hotkeyManager.selectedHotkey1 == .custom,
|
||||
let shortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) {
|
||||
KeyboardShortcutView(shortcut: shortcut)
|
||||
.scaleEffect(1.2)
|
||||
} else if hotkeyManager.selectedHotkey1 != .none && hotkeyManager.selectedHotkey1 != .custom {
|
||||
Text(hotkeyManager.selectedHotkey1.displayName)
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.white.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,6 +161,7 @@ struct OnboardingTutorialView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
hotkeyManager.startHotkeyMonitoring()
|
||||
animateIn()
|
||||
isFocused = true
|
||||
}
|
||||
@ -156,9 +170,9 @@ struct OnboardingTutorialView: View {
|
||||
private func getInstructionText(for step: Int) -> String {
|
||||
switch step {
|
||||
case 1: return "Click the text area on the right"
|
||||
case 2: return "Press your keyboard shortcut"
|
||||
case 2: return "Press your shortcut key"
|
||||
case 3: return "Speak something"
|
||||
case 4: return "Press your keyboard shortcut again"
|
||||
case 4: return "Press your shortcut key again"
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,299 +0,0 @@
|
||||
import SwiftUI
|
||||
import KeyboardShortcuts
|
||||
import AppKit
|
||||
|
||||
struct RecordView: View {
|
||||
@EnvironmentObject var whisperState: WhisperState
|
||||
@EnvironmentObject var hotkeyManager: HotkeyManager
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject private var mediaController = MediaController.shared
|
||||
|
||||
private var hasShortcutSet: Bool {
|
||||
KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
mainContent
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(spacing: 48) {
|
||||
heroSection
|
||||
controlsSection
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
|
||||
private var heroSection: some View {
|
||||
VStack(spacing: 20) {
|
||||
AppIconView()
|
||||
titleSection
|
||||
}
|
||||
}
|
||||
|
||||
private var titleSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
Text("VOICEINK")
|
||||
.font(.system(size: 42, weight: .bold))
|
||||
|
||||
if whisperState.currentTranscriptionModel != nil {
|
||||
Text("Powered by Whisper AI")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var controlsSection: some View {
|
||||
VStack(spacing: 32) {
|
||||
compactControlsCard
|
||||
instructionsCard
|
||||
}
|
||||
}
|
||||
|
||||
private var compactControlsCard: some View {
|
||||
HStack(spacing: 32) {
|
||||
shortcutSection
|
||||
|
||||
if hasShortcutSet {
|
||||
Divider()
|
||||
.frame(height: 40)
|
||||
pushToTalkSection
|
||||
|
||||
Divider()
|
||||
.frame(height: 40)
|
||||
|
||||
// Settings section
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle(isOn: $whisperState.isAutoCopyEnabled) {
|
||||
HStack {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.foregroundColor(.secondary)
|
||||
Text("Auto-copy to clipboard")
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Toggle(isOn: .init(
|
||||
get: { SoundManager.shared.isEnabled },
|
||||
set: { SoundManager.shared.isEnabled = $0 }
|
||||
)) {
|
||||
HStack {
|
||||
Image(systemName: "speaker.wave.2")
|
||||
.foregroundColor(.secondary)
|
||||
Text("Sound feedback")
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Toggle(isOn: $mediaController.isSystemMuteEnabled) {
|
||||
HStack {
|
||||
Image(systemName: "speaker.slash")
|
||||
.foregroundColor(.secondary)
|
||||
Text("Mute system audio during recording")
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.help("Automatically mute system audio when recording starts and restore when recording stops")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(CardBackground(isSelected: false))
|
||||
}
|
||||
|
||||
private var shortcutSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
if hasShortcutSet {
|
||||
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder) {
|
||||
KeyboardShortcutView(shortcut: shortcut)
|
||||
.scaleEffect(1.2)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "keyboard.badge.exclamationmark")
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToDestination,
|
||||
object: nil,
|
||||
userInfo: ["destination": "Settings"]
|
||||
)
|
||||
}) {
|
||||
Text(hasShortcutSet ? "Change" : "Set Shortcut")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var pushToTalkSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Push-to-Talk")
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
if hotkeyManager.isPushToTalkEnabled {
|
||||
SelectableKeyCapView(
|
||||
text: getKeySymbol(for: hotkeyManager.pushToTalkKey),
|
||||
subtext: getKeyText(for: hotkeyManager.pushToTalkKey),
|
||||
isSelected: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getKeySymbol(for key: HotkeyManager.PushToTalkKey) -> String {
|
||||
switch key {
|
||||
case .rightOption: return "⌥"
|
||||
case .leftOption: return "⌥"
|
||||
case .leftControl: return "⌃"
|
||||
case .rightControl: return "⌃"
|
||||
case .fn: return "Fn"
|
||||
case .rightCommand: return "⌘"
|
||||
case .rightShift: return "⇧"
|
||||
}
|
||||
}
|
||||
|
||||
private func getKeyText(for key: HotkeyManager.PushToTalkKey) -> String {
|
||||
switch key {
|
||||
case .rightOption: return "Right Option"
|
||||
case .leftOption: return "Left Option"
|
||||
case .leftControl: return "Left Control"
|
||||
case .rightControl: return "Right Control"
|
||||
case .fn: return "Function"
|
||||
case .rightCommand: return "Right Command"
|
||||
case .rightShift: return "Right Shift"
|
||||
}
|
||||
}
|
||||
|
||||
private var instructionsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
Text("How it works")
|
||||
.font(.title3.weight(.bold))
|
||||
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
ForEach(getInstructions(), id: \.title) { instruction in
|
||||
InstructionRow(instruction: instruction)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
afterRecordingSection
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.background(CardBackground(isSelected: false))
|
||||
}
|
||||
|
||||
private var afterRecordingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("After recording")
|
||||
.font(.headline)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if whisperState.isAutoCopyEnabled {
|
||||
InfoRow(icon: "doc.on.clipboard", text: "Copied to clipboard")
|
||||
}
|
||||
InfoRow(icon: "text.cursor", text: "Pasted at cursor position")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getInstructions() -> [(icon: String, title: String, description: String)] {
|
||||
let keyName: String
|
||||
switch hotkeyManager.pushToTalkKey {
|
||||
case .rightOption:
|
||||
keyName = "right Option (⌥)"
|
||||
case .leftOption:
|
||||
keyName = "left Option (⌥)"
|
||||
case .leftControl:
|
||||
keyName = "left Control (⌃)"
|
||||
case .rightControl:
|
||||
keyName = "right Control (⌃)"
|
||||
case .fn:
|
||||
keyName = "Fn"
|
||||
case .rightCommand:
|
||||
keyName = "right Command (⌘)"
|
||||
case .rightShift:
|
||||
keyName = "right Shift (⇧)"
|
||||
}
|
||||
|
||||
let activateDescription = hotkeyManager.isPushToTalkEnabled ?
|
||||
"Hold the \(keyName) key" :
|
||||
"Press your configured shortcut"
|
||||
|
||||
let finishDescription = hotkeyManager.isPushToTalkEnabled ?
|
||||
"Release the \(keyName) key to stop and process" :
|
||||
"Press the shortcut again to stop"
|
||||
|
||||
return [
|
||||
(
|
||||
icon: "mic.circle.fill",
|
||||
title: "Start Recording",
|
||||
description: activateDescription
|
||||
),
|
||||
(
|
||||
icon: "waveform",
|
||||
title: "Speak Clearly",
|
||||
description: "Talk into your microphone naturally"
|
||||
),
|
||||
(
|
||||
icon: "stop.circle.fill",
|
||||
title: "Finish Up",
|
||||
description: finishDescription
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified InstructionRow
|
||||
struct InstructionRow: View {
|
||||
let instruction: (icon: String, title: String, description: String)
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
Image(systemName: instruction.icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(instruction.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(instruction.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified InfoRow
|
||||
struct InfoRow: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.secondary)
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ struct SettingsView: View {
|
||||
@EnvironmentObject private var whisperState: WhisperState
|
||||
@EnvironmentObject private var enhancementService: AIEnhancementService
|
||||
@StateObject private var deviceManager = AudioDeviceManager.shared
|
||||
@ObservedObject private var mediaController = MediaController.shared
|
||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true
|
||||
@State private var showResetOnboardingAlert = false
|
||||
@State private var currentShortcut = KeyboardShortcuts.getShortcut(for: .toggleMiniRecorder)
|
||||
@ -19,83 +20,143 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Keyboard Shortcuts Section first
|
||||
// Hotkey Selection Section
|
||||
SettingsSection(
|
||||
icon: currentShortcut != nil ? "keyboard" : "keyboard.badge.exclamationmark",
|
||||
title: "Keyboard Shortcuts",
|
||||
subtitle: currentShortcut != nil ? "Shortcut configured" : "Shortcut required",
|
||||
showWarning: currentShortcut == nil
|
||||
icon: "command.circle",
|
||||
title: "VoiceInk Shortcut",
|
||||
subtitle: "Choose how you want to trigger VoiceInk"
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
hotkeyView(
|
||||
title: "Hotkey 1",
|
||||
binding: $hotkeyManager.selectedHotkey1,
|
||||
shortcutName: .toggleMiniRecorder
|
||||
)
|
||||
|
||||
// Hotkey 2 Configuration (Conditional)
|
||||
if hotkeyManager.selectedHotkey2 != .none {
|
||||
Divider()
|
||||
hotkeyView(
|
||||
title: "Hotkey 2",
|
||||
binding: $hotkeyManager.selectedHotkey2,
|
||||
shortcutName: .toggleMiniRecorder2,
|
||||
isRemovable: true,
|
||||
onRemove: {
|
||||
withAnimation { hotkeyManager.selectedHotkey2 = .none }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// "Add another hotkey" button
|
||||
if 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)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// Recording Feedback Section
|
||||
SettingsSection(
|
||||
icon: "speaker.wave.2.bubble.left.fill",
|
||||
title: "Recording Feedback",
|
||||
subtitle: "Customize audio and system feedback"
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle(isOn: $whisperState.isAutoCopyEnabled) {
|
||||
Text("Auto-copy to clipboard")
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Toggle(isOn: .init(
|
||||
get: { SoundManager.shared.isEnabled },
|
||||
set: { SoundManager.shared.isEnabled = $0 }
|
||||
)) {
|
||||
Text("Sound feedback")
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Toggle(isOn: $mediaController.isSystemMuteEnabled) {
|
||||
Text("Mute system audio during recording")
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.help("Automatically mute system audio when recording starts and restore when recording stops")
|
||||
}
|
||||
}
|
||||
|
||||
// Recorder Preference Section
|
||||
SettingsSection(
|
||||
icon: "rectangle.on.rectangle",
|
||||
title: "Recorder Style",
|
||||
subtitle: "Choose your preferred recorder interface"
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if currentShortcut == nil {
|
||||
Text("⚠️ Please set a keyboard shortcut to use VoiceInk")
|
||||
.foregroundColor(.orange)
|
||||
.font(.subheadline)
|
||||
}
|
||||
Text("Select how you want the recorder to appear on your screen.")
|
||||
.settingsDescription()
|
||||
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
if let shortcut = currentShortcut {
|
||||
KeyboardShortcutView(shortcut: shortcut)
|
||||
} else {
|
||||
Text("Not Set")
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
KeyboardShortcuts.reset(.toggleMiniRecorder)
|
||||
currentShortcut = nil
|
||||
}) {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Reset Shortcut")
|
||||
}
|
||||
|
||||
KeyboardShortcuts.Recorder("Change Shortcut:", name: .toggleMiniRecorder) { newShortcut in
|
||||
currentShortcut = newShortcut
|
||||
hotkeyManager.updateShortcutStatus()
|
||||
}
|
||||
.controlSize(.large)
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Enable Push-to-Talk", isOn: $hotkeyManager.isPushToTalkEnabled)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
if hotkeyManager.isPushToTalkEnabled {
|
||||
if currentShortcut == nil {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text("Please set a keyboard shortcut first to use Push-to-Talk")
|
||||
.settingsDescription()
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Choose Push-to-Talk Key")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
PushToTalkKeySelector(selectedKey: $hotkeyManager.pushToTalkKey)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Text("Quick tap the key once to start hands-free recording (tap again to stop).\nPress and hold the key for push-to-talk (release to stop recording).")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
Picker("Recorder Style", selection: $whisperState.recorderType) {
|
||||
Text("Notch Recorder").tag("notch")
|
||||
Text("Mini Recorder").tag("mini")
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Paste Method Section
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// App Appearance Section
|
||||
SettingsSection(
|
||||
icon: "dock.rectangle",
|
||||
title: "App Appearance",
|
||||
subtitle: "Dock and Menu Bar options"
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Choose how VoiceInk appears in your system.")
|
||||
.settingsDescription()
|
||||
|
||||
Toggle("Hide Dock Icon (Menu Bar Only)", isOn: $menuBarManager.isMenuBarOnly)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
|
||||
// Audio Cleanup Section
|
||||
SettingsSection(
|
||||
icon: "trash.circle",
|
||||
title: "Audio Cleanup",
|
||||
subtitle: "Manage recording storage"
|
||||
) {
|
||||
AudioCleanupSettingsView()
|
||||
}
|
||||
|
||||
// Startup Section
|
||||
SettingsSection(
|
||||
@ -130,68 +191,25 @@ struct SettingsView: View {
|
||||
.disabled(!updaterViewModel.canCheckForUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
// App Appearance Section
|
||||
|
||||
// Reset Onboarding Section
|
||||
SettingsSection(
|
||||
icon: "dock.rectangle",
|
||||
title: "App Appearance",
|
||||
subtitle: "Dock and Menu Bar options"
|
||||
icon: "arrow.counterclockwise",
|
||||
title: "Reset Onboarding",
|
||||
subtitle: "View the introduction again"
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Choose how VoiceInk appears in your system.")
|
||||
Text("Reset the onboarding process to view the app introduction again.")
|
||||
.settingsDescription()
|
||||
|
||||
Toggle("Hide Dock Icon (Menu Bar Only)", isOn: $menuBarManager.isMenuBarOnly)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
|
||||
// Paste Method Section
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Recorder Preference Section
|
||||
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")
|
||||
Button("Reset Onboarding") {
|
||||
showResetOnboardingAlert = true
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
.padding(.vertical, 4)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
||||
// Audio Cleanup Section
|
||||
SettingsSection(
|
||||
icon: "trash.circle",
|
||||
title: "Audio Cleanup",
|
||||
subtitle: "Manage recording storage"
|
||||
) {
|
||||
AudioCleanupSettingsView()
|
||||
}
|
||||
|
||||
|
||||
// Data Management Section
|
||||
SettingsSection(
|
||||
icon: "arrow.up.arrow.down.circle",
|
||||
@ -237,24 +255,6 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset Onboarding Section
|
||||
SettingsSection(
|
||||
icon: "arrow.counterclockwise",
|
||||
title: "Reset Onboarding",
|
||||
subtitle: "View the introduction again"
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Reset the onboarding process to view the app introduction again.")
|
||||
.settingsDescription()
|
||||
|
||||
Button("Reset Onboarding") {
|
||||
showResetOnboardingAlert = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 6)
|
||||
@ -270,22 +270,68 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func getPushToTalkDescription() -> String {
|
||||
switch hotkeyManager.pushToTalkKey {
|
||||
case .rightOption:
|
||||
return "Using Right Option (⌥) key to quickly start recording. Release to stop."
|
||||
case .leftOption:
|
||||
return "Using Left Option (⌥) key to quickly start recording. Release to stop."
|
||||
case .leftControl:
|
||||
return "Using Left Control (⌃) key to quickly start recording. Release to stop."
|
||||
case .rightControl:
|
||||
return "Using Right Control (⌃) key to quickly start recording. Release to stop."
|
||||
case .fn:
|
||||
return "Using Function (Fn) key to quickly start recording. Release to stop."
|
||||
case .rightCommand:
|
||||
return "Using Right Command (⌘) key to quickly start recording. Release to stop."
|
||||
case .rightShift:
|
||||
return "Using Right Shift (⇧) key to quickly start recording. Release to stop."
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -354,108 +400,4 @@ extension Text {
|
||||
}
|
||||
}
|
||||
|
||||
struct PushToTalkKeySelector: View {
|
||||
@Binding var selectedKey: HotkeyManager.PushToTalkKey
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(HotkeyManager.PushToTalkKey.allCases, id: \.self) { key in
|
||||
Button(action: {
|
||||
withAnimation(.spring(response: 0.2, dampingFraction: 0.6)) {
|
||||
selectedKey = key
|
||||
}
|
||||
}) {
|
||||
SelectableKeyCapView(
|
||||
text: getKeySymbol(for: key),
|
||||
subtext: getKeyText(for: key),
|
||||
isSelected: selectedKey == key
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getKeySymbol(for key: HotkeyManager.PushToTalkKey) -> String {
|
||||
switch key {
|
||||
case .rightOption: return "⌥"
|
||||
case .leftOption: return "⌥"
|
||||
case .leftControl: return "⌃"
|
||||
case .rightControl: return "⌃"
|
||||
case .fn: return "Fn"
|
||||
case .rightCommand: return "⌘"
|
||||
case .rightShift: return "⇧"
|
||||
}
|
||||
}
|
||||
|
||||
private func getKeyText(for key: HotkeyManager.PushToTalkKey) -> String {
|
||||
switch key {
|
||||
case .rightOption: return "Right Option"
|
||||
case .leftOption: return "Left Option"
|
||||
case .leftControl: return "Left Control"
|
||||
case .rightControl: return "Right Control"
|
||||
case .fn: return "Function"
|
||||
case .rightCommand: return "Right Command"
|
||||
case .rightShift: return "Right Shift"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectableKeyCapView: View {
|
||||
let text: String
|
||||
let subtext: String
|
||||
let isSelected: Bool
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
private var keyColor: Color {
|
||||
if isSelected {
|
||||
return colorScheme == .dark ? Color.accentColor.opacity(0.3) : Color.accentColor.opacity(0.2)
|
||||
}
|
||||
return colorScheme == .dark ? Color(white: 0.2) : .white
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(text)
|
||||
.font(.system(size: 20, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(keyColor)
|
||||
|
||||
// Highlight overlay
|
||||
if isSelected {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.accentColor, lineWidth: 2)
|
||||
}
|
||||
|
||||
// Key surface highlight
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color.white.opacity(colorScheme == .dark ? 0.1 : 0.4),
|
||||
Color.white.opacity(0)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
.shadow(
|
||||
color: Color.black.opacity(colorScheme == .dark ? 0.5 : 0.2),
|
||||
radius: 2,
|
||||
x: 0,
|
||||
y: 1
|
||||
)
|
||||
|
||||
Text(subtext)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,8 +115,6 @@ struct VoiceInkApp: App {
|
||||
.environmentObject(aiService)
|
||||
.environmentObject(enhancementService)
|
||||
.frame(minWidth: 880, minHeight: 780)
|
||||
.cornerRadius(16)
|
||||
.clipped()
|
||||
.background(WindowAccessor { window in
|
||||
// Ensure this is called only once or is idempotent
|
||||
if window.title != "VoiceInk Onboarding" { // Prevent re-configuration
|
||||
|
||||
@ -22,18 +22,18 @@ class WindowManager {
|
||||
}
|
||||
|
||||
func configureOnboardingPanel(_ window: NSWindow) {
|
||||
window.styleMask = [.borderless, .fullSizeContentView, .resizable]
|
||||
window.isMovableByWindowBackground = true
|
||||
window.level = .normal
|
||||
window.styleMask = [.titled, .fullSizeContentView, .resizable]
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
window.isMovableByWindowBackground = true
|
||||
window.level = .normal
|
||||
window.backgroundColor = .clear
|
||||
window.isReleasedWhenClosed = false
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.title = "VoiceInk Onboarding"
|
||||
window.isOpaque = false
|
||||
window.minSize = NSSize(width: 900, height: 780)
|
||||
window.orderFrontRegardless()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
func createMainWindow(contentView: NSView) -> NSWindow {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user