DictionaryViewModel.swift 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import AppKit
  2. import AVFoundation
  3. import Combine
  4. import Foundation
  5. @MainActor
  6. final class DictionaryViewModel: ObservableObject {
  7. static let maxQueryLength = 64
  8. private static let recentSearchesKey = "dictionaryRecentSearches"
  9. private static let maxRecentSearches = 8
  10. @Published var query: String = ""
  11. @Published private(set) var entry: DictionaryEntry?
  12. @Published private(set) var isLoading = false
  13. @Published private(set) var errorMessage: String?
  14. @Published private(set) var recentSearches: [String] = []
  15. private let service: DictionaryServicing
  16. private var speechSynthesizer = AVSpeechSynthesizer()
  17. private var audioPlayer: AVAudioPlayer?
  18. private var lookupTask: Task<Void, Never>?
  19. private var activeLookupID = UUID()
  20. init(service: DictionaryServicing = DictionaryService()) {
  21. self.service = service
  22. recentSearches = Self.loadRecentSearches()
  23. }
  24. var hasResult: Bool { entry != nil }
  25. var canLookUp: Bool {
  26. !normalizedQuery(from: query).isEmpty && !isLoading
  27. }
  28. func lookUp() {
  29. let trimmed = normalizedQuery(from: query)
  30. guard !trimmed.isEmpty else {
  31. clearResult()
  32. errorMessage = DictionaryServiceError.emptyInput.errorDescription
  33. return
  34. }
  35. if query != trimmed {
  36. query = trimmed
  37. }
  38. startLookUp(for: trimmed)
  39. }
  40. func lookUpRecent(_ word: String) {
  41. query = word
  42. startLookUp(for: word)
  43. }
  44. func pasteWord() {
  45. guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
  46. let word = clipboard
  47. .components(separatedBy: .whitespacesAndNewlines)
  48. .first?
  49. .trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)) ?? ""
  50. query = String(word.prefix(Self.maxQueryLength))
  51. clearResult()
  52. }
  53. func clearSearch() {
  54. query = ""
  55. clearResult()
  56. }
  57. func resetResult() {
  58. clearResult()
  59. }
  60. func handleQueryChange(_ newValue: String) {
  61. if newValue.count > Self.maxQueryLength {
  62. query = String(newValue.prefix(Self.maxQueryLength))
  63. return
  64. }
  65. let trimmed = normalizedQuery(from: newValue)
  66. if let entry, !trimmed.isEmpty, trimmed.caseInsensitiveCompare(entry.word) == .orderedSame {
  67. return
  68. }
  69. if hasResult {
  70. clearResult()
  71. }
  72. }
  73. private func normalizedQuery(from value: String) -> String {
  74. value
  75. .trimmingCharacters(in: .whitespacesAndNewlines)
  76. .components(separatedBy: .whitespacesAndNewlines)
  77. .filter { !$0.isEmpty }
  78. .joined(separator: " ")
  79. }
  80. func playPronunciation(for entry: DictionaryEntry) {
  81. if let audioURL = entry.audioURL {
  82. Task {
  83. await playRemoteAudio(from: audioURL)
  84. }
  85. return
  86. }
  87. let utterance = AVSpeechUtterance(string: entry.word)
  88. utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
  89. utterance.rate = AVSpeechUtteranceDefaultSpeechRate
  90. speechSynthesizer.stopSpeaking(at: .immediate)
  91. speechSynthesizer.speak(utterance)
  92. }
  93. private func startLookUp(for word: String) {
  94. lookupTask?.cancel()
  95. let lookupID = UUID()
  96. activeLookupID = lookupID
  97. lookupTask = Task {
  98. await performLookUp(for: word, lookupID: lookupID)
  99. }
  100. }
  101. private func performLookUp(for word: String, lookupID: UUID) async {
  102. isLoading = true
  103. errorMessage = nil
  104. entry = nil
  105. do {
  106. let result = try await service.lookUp(word: word)
  107. guard !Task.isCancelled, activeLookupID == lookupID else { return }
  108. entry = result
  109. query = result.word
  110. addRecentSearch(result.word)
  111. AIFeatureUsage.recordSuccessfulUse()
  112. } catch {
  113. guard !Task.isCancelled, activeLookupID == lookupID else { return }
  114. errorMessage = UserFacingError.message(for: error)
  115. }
  116. guard activeLookupID == lookupID else { return }
  117. isLoading = false
  118. }
  119. private func clearResult() {
  120. entry = nil
  121. errorMessage = nil
  122. }
  123. private func addRecentSearch(_ word: String) {
  124. var updated = recentSearches.filter { $0.caseInsensitiveCompare(word) != .orderedSame }
  125. updated.insert(word, at: 0)
  126. recentSearches = Array(updated.prefix(Self.maxRecentSearches))
  127. UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
  128. }
  129. private static func loadRecentSearches() -> [String] {
  130. UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
  131. }
  132. private func playRemoteAudio(from url: URL) async {
  133. do {
  134. let (data, _) = try await NetworkClient.data(from: url)
  135. audioPlayer = try AVAudioPlayer(data: data)
  136. audioPlayer?.prepareToPlay()
  137. audioPlayer?.play()
  138. } catch {
  139. let utterance = AVSpeechUtterance(string: query)
  140. utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
  141. speechSynthesizer.stopSpeaking(at: .immediate)
  142. speechSynthesizer.speak(utterance)
  143. }
  144. }
  145. }