import Foundation import AVFoundation import SwiftUI class CustomSoundManager: ObservableObject { static let shared = CustomSoundManager() enum SoundType: String { case start case stop var isUsingKey: String { "isUsingCustom\(rawValue.capitalized)Sound" } var filenameKey: String { "custom\(rawValue.capitalized)SoundFilename" } var standardName: String { "Custom\(rawValue.capitalized)Sound" } } @Published var isUsingCustomStartSound: Bool { didSet { UserDefaults.standard.set(isUsingCustomStartSound, forKey: SoundType.start.isUsingKey) } } @Published var isUsingCustomStopSound: Bool { didSet { UserDefaults.standard.set(isUsingCustomStopSound, forKey: SoundType.stop.isUsingKey) } } private let maxSoundDuration: TimeInterval = 3.0 private var customStartSoundFilename: String? { didSet { updateFilenameInUserDefaults(filename: customStartSoundFilename, for: .start) } } private var customStopSoundFilename: String? { didSet { updateFilenameInUserDefaults(filename: customStopSoundFilename, for: .stop) } } private func updateFilenameInUserDefaults(filename: String?, for type: SoundType) { if let filename = filename { UserDefaults.standard.set(filename, forKey: type.filenameKey) } else { UserDefaults.standard.removeObject(forKey: type.filenameKey) } } private init() { self.isUsingCustomStartSound = UserDefaults.standard.bool(forKey: SoundType.start.isUsingKey) self.isUsingCustomStopSound = UserDefaults.standard.bool(forKey: SoundType.stop.isUsingKey) self.customStartSoundFilename = UserDefaults.standard.string(forKey: SoundType.start.filenameKey) self.customStopSoundFilename = UserDefaults.standard.string(forKey: SoundType.stop.filenameKey) createCustomSoundsDirectoryIfNeeded() } private func customSoundsDirectory() -> URL? { guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } return appSupport.appendingPathComponent("VoiceInk/CustomSounds") } private func createCustomSoundsDirectoryIfNeeded() { guard let directory = customSoundsDirectory() else { return } if !FileManager.default.fileExists(atPath: directory.path) { try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } } func getCustomSoundURL(for type: SoundType) -> URL? { let isUsing = (type == .start) ? isUsingCustomStartSound : isUsingCustomStopSound let filename = (type == .start) ? customStartSoundFilename : customStopSoundFilename guard isUsing, let filename = filename, let directory = customSoundsDirectory() else { return nil } return directory.appendingPathComponent(filename) } func setCustomSound(url: URL, for type: SoundType) -> Result { let result = validateAudioFile(url: url) switch result { case .success: let copyResult = copySoundFile(from: url, standardName: type.standardName) switch copyResult { case .success(let filename): if type == .start { customStartSoundFilename = filename isUsingCustomStartSound = true } else { customStopSoundFilename = filename isUsingCustomStopSound = true } notifyCustomSoundsChanged() return .success(()) case .failure(let error): return .failure(error) } case .failure(let error): return .failure(error) } } func resetSoundToDefault(for type: SoundType) { let filename = (type == .start) ? customStartSoundFilename : customStopSoundFilename if let filename = filename, let directory = customSoundsDirectory() { let fileURL = directory.appendingPathComponent(filename) try? FileManager.default.removeItem(at: fileURL) } if type == .start { isUsingCustomStartSound = false customStartSoundFilename = nil } else { isUsingCustomStopSound = false customStopSoundFilename = nil } notifyCustomSoundsChanged() } private func notifyCustomSoundsChanged() { NotificationCenter.default.post(name: NSNotification.Name("CustomSoundsChanged"), object: nil) } func getSoundDisplayName(for type: SoundType) -> String? { return (type == .start) ? customStartSoundFilename : customStopSoundFilename } private func copySoundFile(from sourceURL: URL, standardName: String) -> Result { guard let directory = customSoundsDirectory() else { return .failure(.directoryCreationFailed) } let fileExtension = sourceURL.pathExtension let newFilename = "\(standardName).\(fileExtension)" let destinationURL = directory.appendingPathComponent(newFilename) if sourceURL.resolvingSymlinksInPath() == destinationURL.resolvingSymlinksInPath() { return .success(newFilename) } if FileManager.default.fileExists(atPath: destinationURL.path) { try? FileManager.default.removeItem(at: destinationURL) } do { try FileManager.default.copyItem(at: sourceURL, to: destinationURL) return .success(newFilename) } catch { return .failure(.fileCopyFailed) } } private func validateAudioFile(url: URL) -> Result { guard FileManager.default.fileExists(atPath: url.path) else { return .failure(.fileNotFound) } let asset = AVAsset(url: url) let duration = asset.duration.seconds guard duration.isFinite && duration > 0 else { return .failure(.invalidAudioFile) } if duration > maxSoundDuration { return .failure(.durationTooLong(duration: duration, maxDuration: maxSoundDuration)) } do { _ = try AVAudioPlayer(contentsOf: url) } catch { return .failure(.invalidAudioFile) } return .success(()) } } enum CustomSoundError: LocalizedError { case fileNotFound case invalidAudioFile case durationTooLong(duration: TimeInterval, maxDuration: TimeInterval) case directoryCreationFailed case fileCopyFailed var errorDescription: String? { switch self { case .fileNotFound: return "Audio file not found" case .invalidAudioFile: return "Invalid audio file format" case .durationTooLong(let duration, let maxDuration): return String(format: "Audio file is %.1f seconds long. Please use an audio file that is %.0f seconds or shorter for start and stop sounds.", duration, maxDuration) case .directoryCreationFailed: return "Failed to create custom sounds directory" case .fileCopyFailed: return "Failed to copy audio file" } } }