LNVoiceResourceManager.swift 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. //
  2. // LNVoiceResourceManager.swift
  3. // Lanu
  4. //
  5. // Created by OneeChan on 2026/1/4.
  6. //
  7. import Foundation
  8. import AVFoundation
  9. private class LNVoiceLocalSourceInfo: Codable {
  10. @LNVisitedTimeWrapper
  11. var path: String
  12. init(path: String) {
  13. self.path = path
  14. }
  15. }
  16. class LNVoiceResourceManager {
  17. static let shared = LNVoiceResourceManager()
  18. private let lock = NSLock()
  19. private let maxFileCount = 100
  20. private var voiceFileCache: [LNVoiceSource: [String: LNVoiceLocalSourceInfo]] = LNUserDefaults[.voiceCache, [:]]
  21. private var loadValueAssets: [String: AVURLAsset] = [:]
  22. private var voiceDurationMap: [String: Double] = [:]
  23. func voicePath(_ name: String, type: LNVoiceSource) -> URL {
  24. return URL.voiceCacheFolder
  25. .appendingPathComponent(type.folderName)
  26. .appendingPathComponent(name)
  27. }
  28. func voiceResourceFor(url: String, type: LNVoiceSource,
  29. cachePath: URL? = nil,
  30. completion: ((String) -> Void)?) {
  31. if let cachePath, FileManager.default.fileExists(atPath: cachePath.path) {
  32. completion?(cachePath.path)
  33. return
  34. }
  35. lock.lock()
  36. let cache = voiceFileCache[type]?[url]
  37. lock.unlock()
  38. if let cache {
  39. if FileManager.default.fileExists(atPath: cache.path) {
  40. saveCache()
  41. completion?(cache.path)
  42. return
  43. } else {
  44. lock.lock()
  45. voiceFileCache[type]?.removeValue(forKey: url)
  46. lock.unlock()
  47. saveCache()
  48. }
  49. }
  50. downloadVoice(url: url, type: type, customPath: cachePath, completion: completion)
  51. }
  52. func downloadVoice(url: String, type: LNVoiceSource,
  53. customPath: URL? = nil, completion: ((String) -> Void)? = nil) {
  54. let path = customPath ?? voicePath(url.md5, type: type)
  55. LNFileDownloader.shared.startDownload(
  56. from: url, destinationPath: path, completionHandler: { [weak self] result in
  57. guard let self else { return }
  58. guard case .success(let sourcePath) = result else {
  59. return
  60. }
  61. completion?(sourcePath.path)
  62. DispatchQueue.global().async { [weak self] in
  63. guard let self else { return }
  64. lock.lock()
  65. var cach = voiceFileCache[type] ?? [:]
  66. cach[url] = LNVoiceLocalSourceInfo(path: sourcePath.path)
  67. // 缓存文件数超过限制
  68. if cach.count > maxFileCount {
  69. let keys = cach.sorted { $0.value.$path.visited < $1.value.$path.visited }.map { $0.key }
  70. // LRU 规则, 移除一半旧缓存
  71. keys.prefix(maxFileCount / 2).forEach {
  72. if let path = cach[$0]?.path, FileManager.default.fileExists(atPath: path) {
  73. try? FileManager.default.removeItem(atPath: path)
  74. }
  75. cach.removeValue(forKey: $0)
  76. }
  77. }
  78. voiceFileCache[type] = cach
  79. lock.unlock()
  80. saveCache()
  81. }
  82. })
  83. }
  84. func getRemoteAudioDuration(urlStr: String?, completion: @escaping (TimeInterval?, Error?) -> Void) {
  85. guard let urlStr else {
  86. completion(nil, nil)
  87. return
  88. }
  89. lock.lock()
  90. let cache = voiceDurationMap[urlStr]
  91. lock.unlock()
  92. if let cache {
  93. completion(cache, nil)
  94. return
  95. }
  96. guard let url = URL(string: urlStr) else {
  97. completion(nil, nil)
  98. return
  99. }
  100. let options: [String: Any] = [
  101. AVURLAssetPreferPreciseDurationAndTimingKey: true
  102. ]
  103. let asset = AVURLAsset(url: url, options: options)
  104. loadValueAssets[urlStr] = asset
  105. asset.loadValuesAsynchronously(forKeys: ["duration"]) { [weak self] in
  106. guard let self else { return }
  107. var error: NSError?
  108. let status = asset.statusOfValue(forKey: "duration", error: &error)
  109. runOnMain { [weak self] in
  110. guard let self else { return }
  111. switch status {
  112. case .loaded:
  113. let duration = asset.duration.seconds
  114. lock.lock()
  115. voiceDurationMap[urlStr] = duration
  116. lock.unlock()
  117. completion(duration, nil)
  118. case .failed:
  119. completion(nil, error)
  120. case .cancelled:
  121. completion(nil, NSError(domain: "AudioDuration", code: -1, userInfo: [NSLocalizedDescriptionKey: "加载取消"]))
  122. default:
  123. completion(nil, NSError(domain: "AudioDuration", code: -2, userInfo: [NSLocalizedDescriptionKey: "加载失败"]))
  124. }
  125. loadValueAssets.removeValue(forKey: urlStr)
  126. }
  127. }
  128. }
  129. func cancelLoadingAsset(urlStr: String) {
  130. guard let asset = loadValueAssets.removeValue(forKey: urlStr) else { return }
  131. asset.cancelLoading()
  132. }
  133. }
  134. extension LNVoiceResourceManager {
  135. private func saveCache() {
  136. lock.lock()
  137. LNUserDefaults[.voiceCache] = voiceFileCache
  138. lock.unlock()
  139. }
  140. }