| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174 |
- 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<Void, Never>?
- 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)
- }
- }
- }
|