video-asset-cache.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. /**
  2. * 竖滑视频资源缓存(App 端落盘 + 全端预取调度)
  3. * - App:downloadFile → saveFile,LRU 淘汰
  4. * - H5/小程序:依赖 video http-cache + 仅做预连接(不强制落盘)
  5. */
  6. const INDEX_KEY = 'hl88app:feed_video_cache_index'
  7. const MAX_ENTRIES = 12
  8. const MAX_BYTES = 120 * 1024 * 1024
  9. const MAX_CONCURRENT = 2
  10. /** @type {Map<string, { path: string, size: number, atime: number }>} */
  11. const memory = new Map()
  12. /** @type {Map<string, Promise<string>>} */
  13. const inflight = new Map()
  14. /** @type {string[]} */
  15. const waitQueue = []
  16. let activeCount = 0
  17. let indexLoaded = false
  18. function canPersistFiles() {
  19. // #ifdef APP-PLUS
  20. return true
  21. // #endif
  22. return false
  23. }
  24. function hashUrl(url) {
  25. let h = 0
  26. const s = String(url)
  27. for (let i = 0; i < s.length; i++) {
  28. h = (h << 5) - h + s.charCodeAt(i)
  29. h |= 0
  30. }
  31. return `v${Math.abs(h)}`
  32. }
  33. function loadIndex() {
  34. if (indexLoaded) return
  35. indexLoaded = true
  36. try {
  37. const raw = uni.getStorageSync(INDEX_KEY)
  38. if (!raw || typeof raw !== 'object') return
  39. Object.keys(raw).forEach((url) => {
  40. const row = raw[url]
  41. if (row && row.path) memory.set(url, row)
  42. })
  43. } catch (_) {}
  44. }
  45. function persistIndex() {
  46. try {
  47. const payload = {}
  48. memory.forEach((row, url) => {
  49. payload[url] = row
  50. })
  51. uni.setStorageSync(INDEX_KEY, payload)
  52. } catch (_) {}
  53. }
  54. function touch(url) {
  55. const row = memory.get(url)
  56. if (row) row.atime = Date.now()
  57. }
  58. function totalBytes() {
  59. let sum = 0
  60. memory.forEach((row) => {
  61. sum += Number(row.size) || 0
  62. })
  63. return sum
  64. }
  65. function evictLru() {
  66. while (memory.size >= MAX_ENTRIES || totalBytes() > MAX_BYTES) {
  67. let oldestUrl = ''
  68. let oldestAt = Infinity
  69. memory.forEach((row, url) => {
  70. if (row.atime < oldestAt) {
  71. oldestAt = row.atime
  72. oldestUrl = url
  73. }
  74. })
  75. if (!oldestUrl) break
  76. const victim = memory.get(oldestUrl)
  77. memory.delete(oldestUrl)
  78. if (victim && victim.path) {
  79. // #ifdef APP-PLUS
  80. uni.removeSavedFile({ filePath: victim.path, complete: () => {} })
  81. // #endif
  82. }
  83. }
  84. persistIndex()
  85. }
  86. function pumpQueue() {
  87. if (activeCount >= MAX_CONCURRENT || !waitQueue.length) return
  88. const url = waitQueue.shift()
  89. if (!url || memory.has(url) || inflight.has(url)) {
  90. pumpQueue()
  91. return
  92. }
  93. activeCount += 1
  94. const job = downloadAndSave(url)
  95. .then((local) => {
  96. if (local) {
  97. memory.set(url, { path: local, size: 0, atime: Date.now() })
  98. evictLru()
  99. }
  100. return local || url
  101. })
  102. .finally(() => {
  103. activeCount -= 1
  104. inflight.delete(url)
  105. pumpQueue()
  106. })
  107. inflight.set(url, job)
  108. }
  109. function downloadAndSave(url) {
  110. return new Promise((resolve) => {
  111. // #ifdef APP-PLUS
  112. uni.downloadFile({
  113. url,
  114. success: (res) => {
  115. if (res.statusCode !== 200 || !res.tempFilePath) {
  116. resolve('')
  117. return
  118. }
  119. uni.saveFile({
  120. tempFilePath: res.tempFilePath,
  121. success: (saved) => resolve(saved.savedFilePath || ''),
  122. fail: () => resolve('')
  123. })
  124. },
  125. fail: () => resolve('')
  126. })
  127. // #endif
  128. // #ifndef APP-PLUS
  129. resolve('')
  130. // #endif
  131. })
  132. }
  133. /**
  134. * 解析可播放地址:有本地缓存则返回 file:// 路径
  135. * @param {string} remoteUrl
  136. */
  137. export async function resolvePlaybackUrl(remoteUrl) {
  138. if (!remoteUrl) return ''
  139. loadIndex()
  140. if (!canPersistFiles()) return remoteUrl
  141. const cached = memory.get(remoteUrl)
  142. if (cached && cached.path) {
  143. touch(remoteUrl)
  144. return cached.path
  145. }
  146. if (inflight.has(remoteUrl)) {
  147. return inflight.get(remoteUrl)
  148. }
  149. const job = downloadAndSave(remoteUrl)
  150. .then((local) => {
  151. if (local) {
  152. uni.getSavedFileInfo({
  153. filePath: local,
  154. success: (info) => {
  155. memory.set(remoteUrl, {
  156. path: local,
  157. size: info.size || 0,
  158. atime: Date.now()
  159. })
  160. evictLru()
  161. },
  162. fail: () => {
  163. memory.set(remoteUrl, { path: local, size: 0, atime: Date.now() })
  164. evictLru()
  165. }
  166. })
  167. return local
  168. }
  169. return remoteUrl
  170. })
  171. .finally(() => inflight.delete(remoteUrl))
  172. inflight.set(remoteUrl, job)
  173. return job
  174. }
  175. /**
  176. * 后台预取(不阻塞当前播放)
  177. * @param {string} remoteUrl
  178. * @param {'high'|'low'} [priority]
  179. */
  180. export function prefetchPlaybackUrl(remoteUrl, priority = 'low') {
  181. if (!remoteUrl || !canPersistFiles()) return
  182. loadIndex()
  183. if (memory.has(remoteUrl) || inflight.has(remoteUrl)) return
  184. if (waitQueue.includes(remoteUrl)) return
  185. if (priority === 'high') waitQueue.unshift(remoteUrl)
  186. else waitQueue.push(remoteUrl)
  187. pumpQueue()
  188. }
  189. /** 按当前索引预取相邻视频 */
  190. export function prefetchNeighbors(clips, centerIndex, toRemoteUrl) {
  191. if (!Array.isArray(clips) || !clips.length) return
  192. const order = [centerIndex + 1, centerIndex - 1, centerIndex + 2]
  193. order.forEach((idx, i) => {
  194. const clip = clips[idx]
  195. if (!clip) return
  196. const remote = toRemoteUrl(clip)
  197. if (!remote) return
  198. prefetchPlaybackUrl(remote, i === 0 ? 'high' : 'low')
  199. })
  200. }
  201. export function clearVideoAssetCache() {
  202. memory.forEach((row) => {
  203. if (row.path) {
  204. // #ifdef APP-PLUS
  205. uni.removeSavedFile({ filePath: row.path, complete: () => {} })
  206. // #endif
  207. }
  208. })
  209. memory.clear()
  210. inflight.clear()
  211. waitQueue.length = 0
  212. activeCount = 0
  213. try {
  214. uni.removeStorageSync(INDEX_KEY)
  215. } catch (_) {}
  216. }
  217. export function readCachedPlaybackUrl(remoteUrl) {
  218. loadIndex()
  219. const row = memory.get(remoteUrl)
  220. return row && row.path ? row.path : ''
  221. }
  222. /** 本地文件损坏时剔除并回退远程播放 */
  223. export function dropCachedPlaybackUrl(remoteUrl) {
  224. if (!remoteUrl) return
  225. const row = memory.get(remoteUrl)
  226. memory.delete(remoteUrl)
  227. inflight.delete(remoteUrl)
  228. const idx = waitQueue.indexOf(remoteUrl)
  229. if (idx >= 0) waitQueue.splice(idx, 1)
  230. if (row && row.path) {
  231. // #ifdef APP-PLUS
  232. uni.removeSavedFile({ filePath: row.path, complete: () => {} })
  233. // #endif
  234. }
  235. persistIndex()
  236. }