import AppKit import AVFoundation import Combine import Foundation @MainActor final class DictionaryViewModel: ObservableObject { static let maxQueryLength = 64 private static let recentSearchesKey = "dictionaryRecentSearches" private static let maxRecentSearches = 8 @Published var query: String = "" @Published private(set) var entry: DictionaryEntry? @Published private(set) var isLoading = false @Published private(set) var errorMessage: String? @Published private(set) var recentSearches: [String] = [] private let service: DictionaryServicing private var speechSynthesizer = AVSpeechSynthesizer() private var audioPlayer: AVAudioPlayer? private var lookupTask: Task? private var activeLookupID = UUID() init(service: DictionaryServicing = DictionaryService()) { self.service = service recentSearches = Self.loadRecentSearches() } var hasResult: Bool { entry != nil } var canLookUp: Bool { !normalizedQuery(from: query).isEmpty && !isLoading } func lookUp() { let trimmed = normalizedQuery(from: query) guard !trimmed.isEmpty else { clearResult() errorMessage = DictionaryServiceError.emptyInput.errorDescription return } if query != trimmed { query = trimmed } startLookUp(for: trimmed) } func lookUpRecent(_ word: String) { query = word startLookUp(for: word) } func pasteWord() { guard let clipboard = NSPasteboard.general.string(forType: .string) else { return } let word = clipboard .components(separatedBy: .whitespacesAndNewlines) .first? .trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)) ?? "" query = String(word.prefix(Self.maxQueryLength)) clearResult() } func clearSearch() { query = "" clearResult() } func resetResult() { clearResult() } func handleQueryChange(_ newValue: String) { if newValue.count > Self.maxQueryLength { query = String(newValue.prefix(Self.maxQueryLength)) return } let trimmed = normalizedQuery(from: newValue) if let entry, !trimmed.isEmpty, trimmed.caseInsensitiveCompare(entry.word) == .orderedSame { return } if hasResult { clearResult() } } private func normalizedQuery(from value: String) -> String { value .trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: .whitespacesAndNewlines) .filter { !$0.isEmpty } .joined(separator: " ") } func playPronunciation(for entry: DictionaryEntry) { if let audioURL = entry.audioURL { Task { await playRemoteAudio(from: audioURL) } return } let utterance = AVSpeechUtterance(string: entry.word) utterance.voice = AVSpeechSynthesisVoice(language: "en-US") utterance.rate = AVSpeechUtteranceDefaultSpeechRate speechSynthesizer.stopSpeaking(at: .immediate) speechSynthesizer.speak(utterance) } private func startLookUp(for word: String) { lookupTask?.cancel() let lookupID = UUID() activeLookupID = lookupID lookupTask = Task { await performLookUp(for: word, lookupID: lookupID) } } private func performLookUp(for word: String, lookupID: UUID) async { isLoading = true errorMessage = nil entry = nil do { let result = try await service.lookUp(word: word) guard !Task.isCancelled, activeLookupID == lookupID else { return } entry = result query = result.word addRecentSearch(result.word) AIFeatureUsage.recordSuccessfulUse() } catch { guard !Task.isCancelled, activeLookupID == lookupID else { return } errorMessage = UserFacingError.message(for: error) } guard activeLookupID == lookupID else { return } isLoading = false } private func clearResult() { entry = nil errorMessage = nil } private func addRecentSearch(_ word: String) { var updated = recentSearches.filter { $0.caseInsensitiveCompare(word) != .orderedSame } updated.insert(word, at: 0) recentSearches = Array(updated.prefix(Self.maxRecentSearches)) UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) } private static func loadRecentSearches() -> [String] { UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? [] } private func playRemoteAudio(from url: URL) async { do { let (data, _) = try await NetworkClient.data(from: url) audioPlayer = try AVAudioPlayer(data: data) audioPlayer?.prepareToPlay() audioPlayer?.play() } catch { let utterance = AVSpeechUtterance(string: query) utterance.voice = AVSpeechSynthesisVoice(language: "en-US") speechSynthesizer.stopSpeaking(at: .immediate) speechSynthesizer.speak(utterance) } } }