// // VoicePlayerManager.swift // QuickLocation // // Created by 八条 on 2026/6/5. // import AVFoundation final class VoicePlayerManager: NSObject { static let shared = VoicePlayerManager() enum State { case idle, playing, paused } private var player: AVAudioPlayer? private var downloadTask: URLSessionDataTask? private let audioCache = NSCache() private var currentURL: String? private var playbackID = UUID() private var onStateChange: ((State) -> Void)? private var onFinished: (() -> Void)? private weak var playbackView: VoicePlaybackView? private(set) var state: State = .idle private override init() { super.init() audioCache.totalCostLimit = 20 * 1_024 * 1_024 } func play(urlString: String, playbackView: VoicePlaybackView, onStateChange: @escaping (State) -> Void, onFinished: @escaping () -> Void) { let value = urlString.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { onFinished() return } if currentURL == value, state == .paused, let player { guard activateAudioSession() else { stop() return } self.playbackView = playbackView self.onStateChange = onStateChange self.onFinished = onFinished player.play() updateState(.playing) return } stop() let playbackID = UUID() self.playbackID = playbackID currentURL = value self.playbackView = playbackView self.onStateChange = onStateChange self.onFinished = onFinished if let localURL = Self.localFileURL(from: value) { startPlayer(url: localURL, expectedURL: value) return } guard let remoteURL = Self.remoteURL(from: value) else { finishCurrentPlayback() return } if let cachedData = audioCache.object(forKey: value as NSString) { startPlayer(data: cachedData as Data, expectedURL: value) return } downloadTask = URLSession.shared.dataTask(with: remoteURL) { [weak self] data, response, error in DispatchQueue.main.async { guard let self, self.playbackID == playbackID, self.currentURL == value else { return } self.downloadTask = nil let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 200 guard let data, error == nil, (200..<300).contains(statusCode), !data.isEmpty else { print("Voice download failed: \(error?.localizedDescription ?? "HTTP \(statusCode)")") self.finishCurrentPlayback() return } self.audioCache.setObject(data as NSData, forKey: value as NSString, cost: data.count) self.startPlayer(data: data, expectedURL: value) } } downloadTask?.resume() } func isCurrent(url: String) -> Bool { currentURL == url.trimmingCharacters(in: .whitespacesAndNewlines) } func pause() { guard state == .playing else { return } player?.pause() updateState(.paused) } func stop() { downloadTask?.cancel() downloadTask = nil playbackID = UUID() player?.pause() player?.stop() player = nil finishCurrentPlayback() } private func startPlayer(url: URL, expectedURL: String) { do { let player = try AVAudioPlayer(contentsOf: url) try start(player: player, expectedURL: expectedURL) } catch { print("Voice playback failed: \(error.localizedDescription)") finishCurrentPlayback() } } private func startPlayer(data: Data, expectedURL: String) { do { let player = try AVAudioPlayer(data: data) try start(player: player, expectedURL: expectedURL) } catch { print("Voice playback failed: \(error.localizedDescription)") finishCurrentPlayback() } } private func start(player: AVAudioPlayer, expectedURL: String) throws { guard currentURL == expectedURL else { return } guard activateAudioSession() else { finishCurrentPlayback() return } player.delegate = self _ = player.prepareToPlay() guard player.duration > 0 else { throw NSError( domain: "VoicePlayerManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "invalid audio data"] ) } self.player = player guard player.play() else { self.player = nil throw NSError( domain: "VoicePlayerManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "audio player did not start"] ) } updateState(.playing) } private func updateState(_ state: State) { self.state = state onStateChange?(state) if state == .playing { playbackView?.play() } else { playbackView?.stop() } } private func finishCurrentPlayback() { let previousFinished = onFinished let previousPlaybackView = playbackView onStateChange = nil onFinished = nil playbackView = nil player = nil currentURL = nil state = .idle previousPlaybackView?.stop() previousFinished?() try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } private func activateAudioSession() -> Bool { do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .spokenAudio) try session.setActive(true) return true } catch { print("Voice audio session failed: \(error.localizedDescription)") return false } } private static func localFileURL(from value: String) -> URL? { if let url = URL(string: value), url.isFileURL, FileManager.default.fileExists(atPath: url.path) { return url } if FileManager.default.fileExists(atPath: value) { return URL(fileURLWithPath: value) } let relativePath = value.trimmingCharacters(in: CharacterSet(charactersIn: "/")) if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { let localURL = documentsURL.appendingPathComponent(relativePath) if FileManager.default.fileExists(atPath: localURL.path) { return localURL } } return nil } private static func remoteURL(from value: String) -> URL? { if let url = URL(string: value), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { return url } guard let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: encoded), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil } return url } } extension VoicePlayerManager: AVAudioPlayerDelegate { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { guard self.player === player else { return } finishCurrentPlayback() } func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { guard self.player === player else { return } if let error { print("Voice decode failed: \(error.localizedDescription)") } finishCurrentPlayback() } } protocol VoicePlaybackView: AnyObject { func play() func stop() }