| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
- //
- // LNVoiceResourceManager.swift
- // Lanu
- //
- // Created by OneeChan on 2026/1/4.
- //
- import Foundation
- import AVFoundation
- private class LNVoiceLocalSourceInfo: Codable {
- @LNVisitedTimeWrapper
- var path: String
-
- init(path: String) {
- self.path = path
- }
- }
- class LNVoiceResourceManager {
- static let shared = LNVoiceResourceManager()
- private let lock = NSLock()
- private let maxFileCount = 100
- private var voiceFileCache: [LNVoiceSource: [String: LNVoiceLocalSourceInfo]] = LNUserDefaults[.voiceCache, [:]]
-
- private var loadValueAssets: [String: AVURLAsset] = [:]
- private var voiceDurationMap: [String: Double] = [:]
-
- func voicePath(_ name: String, type: LNVoiceSource) -> URL {
- return URL.voiceCacheFolder
- .appendingPathComponent(type.folderName)
- .appendingPathComponent(name)
- }
-
- func voiceResourceFor(url: String, type: LNVoiceSource,
- cachePath: URL? = nil,
- completion: ((String) -> Void)?) {
- if let cachePath, FileManager.default.fileExists(atPath: cachePath.path) {
- completion?(cachePath.path)
- return
- }
-
- lock.lock()
- let cache = voiceFileCache[type]?[url]
- lock.unlock()
-
- if let cache {
- if FileManager.default.fileExists(atPath: cache.path) {
- saveCache()
- completion?(cache.path)
- return
- } else {
- lock.lock()
- voiceFileCache[type]?.removeValue(forKey: url)
- lock.unlock()
- saveCache()
- }
- }
-
- downloadVoice(url: url, type: type, customPath: cachePath, completion: completion)
- }
-
- func downloadVoice(url: String, type: LNVoiceSource,
- customPath: URL? = nil, completion: ((String) -> Void)? = nil) {
- let path = customPath ?? voicePath(url.md5, type: type)
- LNFileDownloader.shared.startDownload(
- from: url, destinationPath: path, completionHandler: { [weak self] result in
- guard let self else { return }
- guard case .success(let sourcePath) = result else {
- return
- }
-
- completion?(sourcePath.path)
-
- DispatchQueue.global().async { [weak self] in
- guard let self else { return }
- lock.lock()
- var cach = voiceFileCache[type] ?? [:]
- cach[url] = LNVoiceLocalSourceInfo(path: sourcePath.path)
-
- // 缓存文件数超过限制
- if cach.count > maxFileCount {
- let keys = cach.sorted { $0.value.$path.visited < $1.value.$path.visited }.map { $0.key }
- // LRU 规则, 移除一半旧缓存
- keys.prefix(maxFileCount / 2).forEach {
- if let path = cach[$0]?.path, FileManager.default.fileExists(atPath: path) {
- try? FileManager.default.removeItem(atPath: path)
- }
- cach.removeValue(forKey: $0)
- }
- }
-
- voiceFileCache[type] = cach
- lock.unlock()
- saveCache()
- }
- })
- }
-
- func getRemoteAudioDuration(urlStr: String?, completion: @escaping (TimeInterval?, Error?) -> Void) {
- guard let urlStr else {
- completion(nil, nil)
- return
- }
-
- lock.lock()
- let cache = voiceDurationMap[urlStr]
- lock.unlock()
-
- if let cache {
- completion(cache, nil)
- return
- }
-
- guard let url = URL(string: urlStr) else {
- completion(nil, nil)
- return
- }
-
- let options: [String: Any] = [
- AVURLAssetPreferPreciseDurationAndTimingKey: true
- ]
-
- let asset = AVURLAsset(url: url, options: options)
- loadValueAssets[urlStr] = asset
-
- asset.loadValuesAsynchronously(forKeys: ["duration"]) { [weak self] in
- guard let self else { return }
- var error: NSError?
- let status = asset.statusOfValue(forKey: "duration", error: &error)
-
- runOnMain { [weak self] in
- guard let self else { return }
- switch status {
- case .loaded:
- let duration = asset.duration.seconds
- lock.lock()
- voiceDurationMap[urlStr] = duration
- lock.unlock()
- completion(duration, nil)
- case .failed:
- completion(nil, error)
- case .cancelled:
- completion(nil, NSError(domain: "AudioDuration", code: -1, userInfo: [NSLocalizedDescriptionKey: "加载取消"]))
- default:
- completion(nil, NSError(domain: "AudioDuration", code: -2, userInfo: [NSLocalizedDescriptionKey: "加载失败"]))
- }
- loadValueAssets.removeValue(forKey: urlStr)
- }
- }
- }
-
- func cancelLoadingAsset(urlStr: String) {
- guard let asset = loadValueAssets.removeValue(forKey: urlStr) else { return }
- asset.cancelLoading()
- }
- }
- extension LNVoiceResourceManager {
- private func saveCache() {
- lock.lock()
- LNUserDefaults[.voiceCache] = voiceFileCache
- lock.unlock()
- }
- }
|