DictionaryViewModel.swift 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  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. !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoading
  27. }
  28. func lookUp() {
  29. let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
  30. guard !trimmed.isEmpty else {
  31. clearResult()
  32. errorMessage = DictionaryServiceError.emptyInput.errorDescription
  33. return
  34. }
  35. startLookUp(for: trimmed)
  36. }
  37. func lookUpRecent(_ word: String) {
  38. query = word
  39. startLookUp(for: word)
  40. }
  41. func pasteWord() {
  42. guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
  43. let word = clipboard
  44. .components(separatedBy: .whitespacesAndNewlines)
  45. .first?
  46. .trimmingCharacters(in: .punctuationCharacters.union(.whitespacesAndNewlines)) ?? ""
  47. query = String(word.prefix(Self.maxQueryLength))
  48. clearResult()
  49. }
  50. func clearSearch() {
  51. query = ""
  52. clearResult()
  53. }
  54. func resetResult() {
  55. clearResult()
  56. }
  57. func playPronunciation(for entry: DictionaryEntry) {
  58. if let audioURL = entry.audioURL {
  59. Task {
  60. await playRemoteAudio(from: audioURL)
  61. }
  62. return
  63. }
  64. let utterance = AVSpeechUtterance(string: entry.word)
  65. utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
  66. utterance.rate = AVSpeechUtteranceDefaultSpeechRate
  67. speechSynthesizer.stopSpeaking(at: .immediate)
  68. speechSynthesizer.speak(utterance)
  69. }
  70. private func startLookUp(for word: String) {
  71. lookupTask?.cancel()
  72. let lookupID = UUID()
  73. activeLookupID = lookupID
  74. lookupTask = Task {
  75. await performLookUp(for: word, lookupID: lookupID)
  76. }
  77. }
  78. private func performLookUp(for word: String, lookupID: UUID) async {
  79. isLoading = true
  80. errorMessage = nil
  81. entry = nil
  82. do {
  83. let result = try await service.lookUp(word: word)
  84. guard !Task.isCancelled, activeLookupID == lookupID else { return }
  85. entry = result
  86. query = result.word
  87. addRecentSearch(result.word)
  88. } catch {
  89. guard !Task.isCancelled, activeLookupID == lookupID else { return }
  90. errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
  91. }
  92. guard activeLookupID == lookupID else { return }
  93. isLoading = false
  94. }
  95. private func clearResult() {
  96. entry = nil
  97. errorMessage = nil
  98. }
  99. private func addRecentSearch(_ word: String) {
  100. var updated = recentSearches.filter { $0.caseInsensitiveCompare(word) != .orderedSame }
  101. updated.insert(word, at: 0)
  102. recentSearches = Array(updated.prefix(Self.maxRecentSearches))
  103. UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
  104. }
  105. private static func loadRecentSearches() -> [String] {
  106. UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
  107. }
  108. private func playRemoteAudio(from url: URL) async {
  109. do {
  110. let (data, _) = try await NetworkClient.data(from: url)
  111. audioPlayer = try AVAudioPlayer(data: data)
  112. audioPlayer?.prepareToPlay()
  113. audioPlayer?.play()
  114. } catch {
  115. let utterance = AVSpeechUtterance(string: query)
  116. utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
  117. speechSynthesizer.stopSpeaking(at: .immediate)
  118. speechSynthesizer.speak(utterance)
  119. }
  120. }
  121. }