diff --git a/speaktype/App/speaktypeApp.swift b/speaktype/App/speaktypeApp.swift index ae27243..6a1ff36 100644 --- a/speaktype/App/speaktypeApp.swift +++ b/speaktype/App/speaktypeApp.swift @@ -36,6 +36,7 @@ struct speaktypeApp: App { .preferredColorScheme(.dark) .tint(.appRed) } + .defaultSize(width: 1200, height: 800) .handlesExternalEvents(matching: ["main-dashboard", "open"]) // Only open for matching IDs .commands { SidebarCommands() diff --git a/speaktype/Assets.xcassets/AppIcon.appiconset/Contents.json b/speaktype/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..0386aff 100644 --- a/speaktype/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/speaktype/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,58 +1,74 @@ { - "images" : [ + "images": [ { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" + "size": "16x16", + "idiom": "mac", + "filename": "icon_16x16.png", + "scale": "1x" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" + "size": "16x16", + "idiom": "mac", + "filename": "icon_32x32.png", + "scale": "2x" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" + "size": "32x32", + "idiom": "mac", + "filename": "icon_32x32.png", + "scale": "1x" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" + "size": "32x32", + "idiom": "mac", + "filename": "icon_64x64.png", + "scale": "2x" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" + "size": "128x128", + "idiom": "mac", + "filename": "icon_128x128.png", + "scale": "1x" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" + "size": "128x128", + "idiom": "mac", + "filename": "icon_256x256.png", + "scale": "2x" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" + "size": "256x256", + "idiom": "mac", + "filename": "icon_256x256.png", + "scale": "1x" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" + "size": "256x256", + "idiom": "mac", + "filename": "icon_512x512.png", + "scale": "2x" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" + "size": "512x512", + "idiom": "mac", + "filename": "icon_512x512.png", + "scale": "1x" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" + "size": "512x512", + "idiom": "mac", + "filename": "icon_1024x1024.png", + "scale": "2x" + }, + { + "size": "1024x1024", + "idiom": "mac", + "filename": "icon_1024x1024.png", + "scale": "1x" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "version": 1, + "author": "xcode" } -} +} \ No newline at end of file diff --git a/speaktype/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png new file mode 100644 index 0000000..4844ec4 Binary files /dev/null and b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png differ diff --git a/speaktype/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..4f403ca Binary files /dev/null and b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/speaktype/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..02f87a8 Binary files /dev/null and b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/speaktype/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..c07655f Binary files /dev/null and b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/speaktype/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..8bb947d Binary files /dev/null and b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/speaktype/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..2c9180b Binary files /dev/null and b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/speaktype/Assets.xcassets/AppIcon.appiconset/icon_64x64.png b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_64x64.png new file mode 100644 index 0000000..91bf5fc Binary files /dev/null and b/speaktype/Assets.xcassets/AppIcon.appiconset/icon_64x64.png differ diff --git a/speaktype/Controllers/MiniRecorderWindowController.swift b/speaktype/Controllers/MiniRecorderWindowController.swift index 8544ef8..b2937b9 100644 --- a/speaktype/Controllers/MiniRecorderWindowController.swift +++ b/speaktype/Controllers/MiniRecorderWindowController.swift @@ -4,7 +4,7 @@ import SwiftUI class MiniRecorderWindowController: NSObject { private var panel: NSPanel? - private var hostingController: NSHostingController? + private var hostingController: NSHostingController? // Toggle visibility func toggle() { @@ -41,6 +41,11 @@ class MiniRecorderWindowController: NSObject { // panel.orderFrontDuringApplicationMakeKey() // Does not exist // panel.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) + + // Trigger instant recording + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + NotificationCenter.default.post(name: .hotkeyTriggered, object: nil) + } } } @@ -55,25 +60,31 @@ class MiniRecorderWindowController: NSObject { } ) - hostingController = NSHostingController(rootView: recorderView) + // Initialize hosting controller with transparent background view + // Wrap in AnyView because .background() changes the type from MiniRecorderView to some View + hostingController = NSHostingController(rootView: AnyView(recorderView.background(Color.clear))) let p = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 300, height: 60), - styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView, .hudWindow], + styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], backing: .buffered, defer: false ) + p.isOpaque = false + p.backgroundColor = .clear + p.contentViewController = hostingController p.titleVisibility = .hidden p.titlebarAppearsTransparent = true p.isMovableByWindowBackground = true + p.hasShadow = false // Remove shadow to kill "boundary" artifact // Window Behavior p.level = .floating p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] p.isReleasedWhenClosed = false - p.hidesOnDeactivate = false // Keep floating even if focus lost? Yes. + p.hidesOnDeactivate = false // Keep floating even if focus lost p.standardWindowButton(.closeButton)?.isHidden = true p.standardWindowButton(.miniaturizeButton)?.isHidden = true p.standardWindowButton(.zoomButton)?.isHidden = true @@ -96,19 +107,26 @@ class MiniRecorderWindowController: NSObject { // 3. Wait for focus switch print("Waiting for focus switch...") - try? await Task.sleep(nanoseconds: 800_000_000) // 0.8s + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1.0s wait for stability + + // 4. Robust Paste Routine + print("Attempting Paste Routine...") - // 4. Paste if ClipboardService.shared.isAccessibilityTrusted { - print("Simulating Paste (Accessibility Trusted)...") - ClipboardService.shared.paste() - print("Paste command sent.") - } else { - print("⚠️ Accessibility Permission Missing! Cannot paste. Requesting system prompt...") - // Trigger native system prompt which is more reliable for re-association + // User has permissions, but CGEvent is failing for them. + // Switch to AppleScript (System Events) as PRIMARY method. It's slower but 100% reliable. + print("✅ Accessibility Trusted. Using robust AppleScript paste.") + + // Small delay to ensure 'System Events' is ready + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s + await MainActor.run { - ClipboardService.shared.requestAccessibilityPermission() + ClipboardService.shared.appleScriptPaste() } + } else { + // No permissions? Try CGEvent as a distinct "Hail Mary" that sometimes slips through + print("⚠️ Accessibility Untrusted. Trying CGEvent fallback.") + ClipboardService.shared.paste() } } } diff --git a/speaktype/Models/AIModel.swift b/speaktype/Models/AIModel.swift new file mode 100644 index 0000000..890c1cb --- /dev/null +++ b/speaktype/Models/AIModel.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct AIModel: Identifiable, Equatable { + let id = UUID() + let name: String + let variant: String + let details: String + let rating: String + let size: String + + static let availableModels: [AIModel] = [ + AIModel( + name: "Distil-Whisper Large v3", + variant: "distil-whisper/distil-large-v3", + details: "English • High Quality • Fast", + rating: "Excellent", + size: "756 MB" + ), + AIModel( + name: "Whisper Large v3", + variant: "openai/whisper-large-v3", + details: "Multilingual • Best Accuracy • Slower", + rating: "Best", + size: "3.1 GB" + ), + AIModel( + name: "Whisper Medium", + variant: "openai/whisper-medium", + details: "Multilingual • Balanced", + rating: "Great", + size: "1.5 GB" + ), + AIModel( + name: "Distil-Whisper Medium", + variant: "distil-whisper/distil-medium.en", + details: "English-only • Fast • Good Accuracy", + rating: "Good", + size: "396 MB" + ), + AIModel( + name: "Whisper Base", + variant: "openai_whisper-base", + details: "English-only • Optimized for Apple Silicon", + rating: "Standard", + size: "74 MB" + ), + AIModel( + name: "Whisper Small", + variant: "openai_whisper-small", + details: "English-only • Higher accuracy", + rating: "Good", + size: "244 MB" + ), + AIModel( + name: "Whisper Tiny", + variant: "openai_whisper-tiny", + details: "Multilingual • Fastest", + rating: "Basic", + size: "39 MB" + ) + ] +} diff --git a/speaktype/Resources/Info.plist b/speaktype/Resources/Info.plist index c82ced8..11a01f6 100644 --- a/speaktype/Resources/Info.plist +++ b/speaktype/Resources/Info.plist @@ -32,7 +32,7 @@ LSUIElement - + NSMicrophoneUsageDescription SpeakType needs access to your microphone to record your voice for transcription. All processing is done locally on your device. NSAppleEventsUsageDescription diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index 4859ab8..54bff68 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -68,8 +68,9 @@ class AudioRecordingService: NSObject, ObservableObject { let power = recorder.averagePower(forChannel: 0) // -160 to 0 dB // Normalize: -60dB to 0dB -> 0.0 to 1.0 let normalized = max(0.0, (power + 60) / 60) + // Noise Gate: Ignore background noise below ~10% (approx -54dB) DispatchQueue.main.async { - self.audioLevel = normalized + self.audioLevel = normalized < 0.15 ? 0.0 : normalized } } } diff --git a/speaktype/Services/ClipboardService.swift b/speaktype/Services/ClipboardService.swift index 5a3a0bb..2f89a9c 100644 --- a/speaktype/Services/ClipboardService.swift +++ b/speaktype/Services/ClipboardService.swift @@ -11,7 +11,13 @@ class ClipboardService { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) - print("Copied to clipboard: \(text.prefix(20))...") + + // Verify write + if let check = pasteboard.string(forType: .string), check == text { + print("✅ Clipboard Write Verified: '\(check.prefix(20))...'") + } else { + print("❌ Clipboard Write FAILED!") + } } // Paste content (Simulate Cmd+V) @@ -43,7 +49,20 @@ class ClipboardService { cmdUp?.post(tap: .cghidEventTap) print("Simulated Cmd+V") - print("Simulated Cmd+V") + } + } + + // Fallback using AppleScript (more robust for some apps) + func appleScriptPaste() { + let script = "tell application \"System Events\" to keystroke \"v\" using command down" + if let appleScript = NSAppleScript(source: script) { + var error: NSDictionary? + appleScript.executeAndReturnError(&error) + if let error = error { + print("AppleScript Paste Error: \(error)") + } else { + print("Executed AppleScript Paste") + } } } diff --git a/speaktype/Services/HistoryService.swift b/speaktype/Services/HistoryService.swift index 01e66c8..3f60962 100644 --- a/speaktype/Services/HistoryService.swift +++ b/speaktype/Services/HistoryService.swift @@ -7,6 +7,14 @@ struct HistoryItem: Identifiable, Codable, Hashable { let date: Date let transcript: String let duration: TimeInterval + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: HistoryItem, rhs: HistoryItem) -> Bool { + lhs.id == rhs.id + } } class HistoryService: ObservableObject { diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index dd5acd1..b5acf63 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -6,9 +6,29 @@ class ModelDownloadService: ObservableObject { static let shared = ModelDownloadService() @Published var downloadProgress: [String: Double] = [:] // Map Model Variant (String) to progress + @Published var downloadError: [String: String] = [:] // Debugging: track errors @Published var isDownloading: [String: Bool] = [:] - private init() {} + private init() { + // Force a custom cache directory to avoid "Multiple models found" conflicts + setupCustomCache() + } + + private func setupCustomCache() { + guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } + + // ~/Library/Application Support/SpeakType/huggingface + let customCache = appSupport.appendingPathComponent("SpeakType/huggingface") + + do { + try FileManager.default.createDirectory(at: customCache, withIntermediateDirectories: true) + // Set HF_HUB_CACHE to this clean directory + setenv("HF_HUB_CACHE", customCache.path, 1) + print("Redirected HF Cache to: \(customCache.path)") + } catch { + print("Failed to setup custom cache: \(error)") + } + } // Asynchronous download using WhisperKit func downloadModel(variant: String) { @@ -16,9 +36,14 @@ class ModelDownloadService: ObservableObject { isDownloading[variant] = true downloadProgress[variant] = 0.0 + downloadError[variant] = nil print("Starting WhisperKit download for: \(variant)") Task { + // Debug: List what WhisperKit sees + // Note: WhisperKit API might differ, but let's try to see if we can get info. + // If fetchAvailableModels exists. + do { // Determine model variant enum/string // Note: WhisperKit.download(variant:from:) is the likely API. @@ -46,14 +71,127 @@ class ModelDownloadService: ObservableObject { } } catch { print("WhisperKit download error: \(error)") + + // Auto-Repair: If duplicate models found, delete and retry ONCE + if error.localizedDescription.contains("Multiple models found") { + print("Corruption detected. cleaning cache and retrying...") + Task { @MainActor in + self.downloadError[variant] = "Repairing..." + } + + let log = await self.deleteModel(variant: variant) + + Task { @MainActor in + self.downloadError[variant] = "Repairing... \(log)" + try? await Task.sleep(nanoseconds: 1_000_000_000) // Sleep 1s to let FS settle + } + + // Retry download + Task { + do { + let path = try await WhisperKit.download(variant: variant, progressCallback: { progress in + DispatchQueue.main.async { + self.downloadProgress[variant] = progress.fractionCompleted + } + }) + DispatchQueue.main.async { + self.isDownloading[variant] = false + self.downloadProgress[variant] = 1.0 + self.downloadError[variant] = nil + } + } catch { + DispatchQueue.main.async { + self.isDownloading[variant] = false + self.downloadProgress[variant] = 0.0 + self.downloadError[variant] = error.localizedDescription + } + } + } + return + } + DispatchQueue.main.async { self.isDownloading[variant] = false self.downloadProgress[variant] = 0.0 + // Append hint + self.downloadError[variant] = error.localizedDescription + "\n(Try Trash icon)" } } } } + // Aggressively deletes any potential cache for this variant + func deleteModel(variant: String) async -> String { + let fileManager = FileManager.default + let searchDirs: [FileManager.SearchPathDirectory] = [.documentDirectory, .applicationSupportDirectory, .cachesDirectory] + + // "openai/whisper-large-v3" -> "whisper-large-v3" + let variantParts = variant.split(separator: "/") + let modelName = variantParts.last ?? Substring(variant) + + var deletedCount = 0 + var checkedPaths: [String] = [] + + // 1. Check Standard macOS Paths + for searchDir in searchDirs { + guard let baseDir = fileManager.urls(for: searchDir, in: .userDomainMask).first else { continue } + + // Check ./huggingface/models + let hfDir = baseDir.appendingPathComponent("huggingface/models") + checkedPaths.append(hfDir.path) // Checks: ~/Library/Application Support/huggingface/models + deletedCount += cleanupDirectory(hfDir, match: modelName) + + // Check ./ (root of Documents/Support, sometimes WhisperKit dumps here) + // check baseDir.path + deletedCount += cleanupDirectory(baseDir, match: modelName) + } + + // 2. Check ~/.cache (Common for Python/Unix HF tools) + let homeDir = fileManager.homeDirectoryForCurrentUser + let dotCache = homeDir.appendingPathComponent(".cache/huggingface/models") + checkedPaths.append(dotCache.path) + deletedCount += cleanupDirectory(dotCache, match: modelName) + + // 3. Explicit Container Check (Fallback) + let container = homeDir.appendingPathComponent("Library/Containers/org.speaktype.speaktype/Data/Documents/huggingface/models") + checkedPaths.append(container.path) + deletedCount += cleanupDirectory(container, match: modelName) + + // 4. Check Temporary Directory + let tempDir = fileManager.temporaryDirectory + // check tempDir/huggingface/models and just tempDir + let tempHf = tempDir.appendingPathComponent("huggingface/models") + checkedPaths.append(tempDir.path) + checkedPaths.append(tempHf.path) + deletedCount += cleanupDirectory(tempDir, match: modelName) + deletedCount += cleanupDirectory(tempHf, match: modelName) + + if deletedCount > 0 { + return "Deleted \(deletedCount) items" + } else { + // Return only first 2 checked paths for brevity if they exist, or just summary + return "No match for '\(modelName)' in \(checkedPaths.count) locations. checked: \(checkedPaths.map { $0.replacingOccurrences(of: homeDir.path, with: "~") }.joined(separator: ", "))" + } + } + + private func cleanupDirectory(_ dir: URL, match: Substring) -> Int { + let fileManager = FileManager.default + guard let contents = try? fileManager.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return 0 } + + var count = 0 + for url in contents { + if url.lastPathComponent.contains(match) { + do { + try fileManager.removeItem(at: url) + print("Deleted corrupted cache: \(url.path)") + count += 1 + } catch { + print("Failed to delete \(url.path): \(error)") + } + } + } + return count + } func cancelDownload(for variant: String) { // WhisperKit might not support cancellation easily via this simple wrapper // For now, just reset state @@ -61,4 +199,3 @@ class ModelDownloadService: ObservableObject { downloadProgress[variant] = 0.0 } } - diff --git a/speaktype/Views/AIModelsView.swift b/speaktype/Views/AIModelsView.swift index 8137515..2a8441b 100644 --- a/speaktype/Views/AIModelsView.swift +++ b/speaktype/Views/AIModelsView.swift @@ -5,23 +5,7 @@ struct AIModelsView: View { @AppStorage("selectedModelVariant") private var selectedModel: String = "openai_whisper-base" // Using standard WhisperKit model variants - @State private var models = [ - AIModel( - name: "Whisper Base", - variant: "openai_whisper-base", - details: "English-only • Optimized for Apple Silicon" - ), - AIModel( - name: "Whisper Small", - variant: "openai_whisper-small", - details: "English-only • Higher accuracy" - ), - AIModel( - name: "Whisper Tiny", - variant: "openai_whisper-tiny", - details: "Multilingual • Fastest" - ) - ] + @State private var models = AIModel.availableModels // Helper to get name of selected model var selectedModelName: String { @@ -83,13 +67,7 @@ struct AIModelsView: View { } } -struct AIModel: Identifiable { - let id = UUID() - let name: String - let variant: String - let details: String - // status is derived from service now -} + struct ModelRow: View { @Binding var model: AIModel @@ -113,6 +91,10 @@ struct ModelRow: View { selectedModel == model.variant } + var downloadError: String? { + downloadService.downloadError[model.variant] + } + var body: some View { HStack { VStack(alignment: .leading, spacing: 4) { @@ -120,15 +102,36 @@ struct ModelRow: View { Text(model.name) .font(.headline) .foregroundStyle(.white) + + // Rating Badge + Text(model.rating) + .font(.caption2) + .fontWeight(.bold) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ratingColor(for: model.rating).opacity(0.2)) + .foregroundStyle(ratingColor(for: model.rating)) + .clipShape(Capsule()) + if isActive { Text("(Active)") .font(.caption) .foregroundStyle(Color.green) } } - Text(model.details) - .font(.caption) - .foregroundStyle(.gray) + HStack(spacing: 6) { + Text(model.details) + .font(.caption) + .foregroundStyle(.gray) + + Text("•") + .font(.caption) + .foregroundStyle(.gray) + + Text(model.size) + .font(.caption) + .foregroundStyle(.gray.opacity(0.8)) + } } Spacer() @@ -170,20 +173,46 @@ struct ModelRow: View { .background(Color.black.opacity(0.2)) .cornerRadius(16) } else { - Button(action: { - downloadService.downloadModel(variant: model.variant) - }) { - HStack(spacing: 4) { - Text("Download") - Image(systemName: "arrow.down.circle") + VStack(alignment: .trailing) { + Button(action: { + downloadService.downloadModel(variant: model.variant) + }) { + HStack(spacing: 4) { + Text("Download") + Image(systemName: "arrow.down.circle") + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.appRed) + .foregroundStyle(.white) + .cornerRadius(16) + } + .buttonStyle(.plain) + + // Debug: Manual Cache Clear + Button(action: { + Task { + let result = await downloadService.deleteModel(variant: model.variant) + await MainActor.run { + downloadService.downloadError[model.variant] = "Manual Delete: \(result)" + } + } + }) { + Image(systemName: "trash") + .foregroundStyle(.gray) + .padding(6) + } + .buttonStyle(.plain) + .help("Force Delete Cache") + + if let error = downloadError { + Text(error) + .font(.caption2) + .foregroundStyle(.red) + .multilineTextAlignment(.trailing) + .frame(maxWidth: 150) } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.appRed) - .foregroundStyle(.white) - .cornerRadius(16) } - .buttonStyle(.plain) } } .padding() @@ -194,4 +223,14 @@ struct ModelRow: View { .stroke(isActive ? Color.green.opacity(0.5) : Color.white.opacity(0.05), lineWidth: 1) ) } + + func ratingColor(for rating: String) -> Color { + switch rating { + case "Best", "Excellent": return .purple + case "Great", "Good": return .green + case "Standard": return .blue + case "Basic": return .gray + default: return .white + } + } } diff --git a/speaktype/Views/HistoryView.swift b/speaktype/Views/HistoryView.swift index b1d02ee..a458b42 100644 --- a/speaktype/Views/HistoryView.swift +++ b/speaktype/Views/HistoryView.swift @@ -3,6 +3,7 @@ import SwiftUI struct HistoryView: View { @StateObject private var historyService = HistoryService.shared @State private var selectedItem: HistoryItem? + @State private var showCopyAlert = false var body: some View { NavigationSplitView { @@ -12,6 +13,7 @@ struct HistoryView: View { Text(item.transcript.prefix(50) + (item.transcript.count > 50 ? "..." : "")) .font(.headline) .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .leading) HStack { Text(item.date.formatted(date: .abbreviated, time: .shortened)) @@ -22,6 +24,7 @@ struct HistoryView: View { .foregroundStyle(.gray) } .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) .contextMenu { Button("Delete", role: .destructive) { historyService.deleteItem(id: item.id) @@ -36,36 +39,52 @@ struct HistoryView: View { .listStyle(.sidebar) } detail: { if let item = selectedItem { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - HStack { - Text(item.date.formatted(date: .long, time: .standard)) - .font(.subheadline) - .foregroundStyle(.gray) - - Spacer() - - Button(action: { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(item.transcript, forType: .string) - }) { - Label("Copy", systemImage: "doc.on.doc") - } - } + VStack(spacing: 0) { + // Header Bar + HStack { + Text(item.date.formatted(date: .long, time: .standard)) + .font(.subheadline) + .foregroundStyle(.gray) - Divider() + Spacer() + Button(action: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(item.transcript, forType: .string) + showCopyAlert = true + }) { + Label("Copy", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + .controlSize(.small) + .alert("Text Copied", isPresented: $showCopyAlert) { + Button("OK", role: .cancel) { } + } message: { + Text("Transcript has been copied to clipboard.") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + // Edge-to-Edge Transcript + ScrollView { Text(item.transcript) .font(.body) .foregroundStyle(.white) .textSelection(.enabled) .lineSpacing(4) - - Spacer() + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 20) + .multilineTextAlignment(.leading) } - .padding(30) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.contentBackground) } - .background(Color.contentBackground) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { Text("Select a recording to view transcript") .foregroundStyle(.gray) diff --git a/speaktype/Views/MainView.swift b/speaktype/Views/MainView.swift index a9cb5db..aa86cb3 100644 --- a/speaktype/Views/MainView.swift +++ b/speaktype/Views/MainView.swift @@ -29,7 +29,7 @@ struct MainView: View { .foregroundStyle(.white) } } - .navigationTitle("SpeakType") + // .navigationTitle("SpeakType") // Removed for clean look .fontDesign(.rounded) // Global modern font design } } diff --git a/speaktype/Views/MiniRecorderView.swift b/speaktype/Views/MiniRecorderView.swift index 513503c..fe458c6 100644 --- a/speaktype/Views/MiniRecorderView.swift +++ b/speaktype/Views/MiniRecorderView.swift @@ -43,8 +43,8 @@ struct MiniRecorderView: View { ForEach(0..<12) { index in RoundedRectangle(cornerRadius: 2) .fill(isListening ? Color.appRed : Color.white.opacity(0.3)) // Red when active - .frame(width: 4, height: 24) // Vertical bars instead of dots for better visibility - .scaleEffect(y: isListening ? CGFloat(1.0 + (Double(audioRecorder.audioLevel) * 5.0)) : 1.0) // Vertical scaling + .frame(width: 4, height: 24) // Base height + .scaleEffect(y: isListening ? min(CGFloat(1.0 + (Double(audioRecorder.audioLevel) * 5.0)), 2.0) : 1.0, anchor: .center) // Bound scale .opacity(isListening ? 1.0 : 0.3) .animation( isListening ? .easeInOut(duration: 0.1) : .default, @@ -59,9 +59,9 @@ struct MiniRecorderView: View { // Model Selector Menu { Picker("Model", selection: $selectedModel) { - Text("Base (Recommended)").tag("openai_whisper-base") - Text("Small (High Accuracy)").tag("openai_whisper-small") - Text("Tiny (Fastest)").tag("openai_whisper-tiny") + ForEach(AIModel.availableModels) { model in + Text(model.name).tag(model.variant) + } } } label: { Image(systemName: "cpu") @@ -73,7 +73,7 @@ struct MiniRecorderView: View { } .menuStyle(.borderlessButton) .fixedSize() - .onChange(of: selectedModel) { newValue in + .onChange(of: selectedModel) { oldValue, newValue in switchModel(to: newValue) } diff --git a/speaktype/Views/OnboardingView.swift b/speaktype/Views/OnboardingView.swift index ac2f054..4e5d6db 100644 --- a/speaktype/Views/OnboardingView.swift +++ b/speaktype/Views/OnboardingView.swift @@ -13,29 +13,21 @@ struct OnboardingView: View { .ignoresSafeArea() // Content ZStack - ScrollView { ZStack { if currentPage == 0 { WelcomePage(action: { withAnimation { currentPage = 1 } }) .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) - } else if currentPage == 1 { - PermissionsPage(finishAction: { - withAnimation { currentPage = 2 } - }) - .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) } else { - // Tutorial / Final Step - WelcomePage(action: { + PermissionsPage(finishAction: { completeOnboarding() }) .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) } } .padding(40) - .frame(maxWidth: .infinity, minHeight: 600) // Ensure layout has space but scrolls - } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } .frame(minWidth: 600, minHeight: 500) // Lower minimum size @@ -72,7 +64,7 @@ struct WelcomePage: View { .frame(maxWidth: 500) } - VStack(spacing: 24) { + HStack(spacing: 24) { FeatureCard(icon: "lock.shield.fill", title: "Private by Design", description: "Your audio never leaves your device.") FeatureCard(icon: "bolt.fill", title: "Lightning Fast", description: "Optimized for Apple Silicon.") FeatureCard(icon: "keyboard.fill", title: "Global Injection", description: "Type with your voice anywhere.") diff --git a/speaktype/Views/SidebarView.swift b/speaktype/Views/SidebarView.swift index b5bae31..7b2b92f 100644 --- a/speaktype/Views/SidebarView.swift +++ b/speaktype/Views/SidebarView.swift @@ -29,12 +29,7 @@ struct SidebarView: View { var body: some View { List(selection: $selection) { - Section { - Label("SpeakType", systemImage: "mic.fill") - .font(.headline) - .foregroundStyle(.white) - .padding(.vertical, 10) - } + // Header removed for clean design Section { ForEach(SidebarItem.allCases) { item in diff --git a/speaktype/Views/TranscribeAudioView.swift b/speaktype/Views/TranscribeAudioView.swift index 66b63d0..de2ad59 100644 --- a/speaktype/Views/TranscribeAudioView.swift +++ b/speaktype/Views/TranscribeAudioView.swift @@ -114,7 +114,7 @@ struct TranscribeAudioView: View { do { transcribedText = try await whisperService.transcribe(audioFile: url) // Save to History - let duration = getAudioDuration(url: url) + let duration = try await getAudioDuration(url: url) HistoryService.shared.addItem(transcript: transcribedText, duration: duration) } catch { transcribedText = "Error: \(error.localizedDescription)" @@ -123,9 +123,10 @@ struct TranscribeAudioView: View { } } - private func getAudioDuration(url: URL) -> TimeInterval { - // Simple duration check using AVURLAsset + private func getAudioDuration(url: URL) async throws -> TimeInterval { + // Async duration check using AVURLAsset let asset = AVURLAsset(url: url) - return CMTimeGetSeconds(asset.duration) + let duration = try await asset.load(.duration) + return CMTimeGetSeconds(duration) } }