/** * 竖滑视频资源缓存(App 端落盘 + 全端预取调度) * - App:downloadFile → saveFile,LRU 淘汰 * - H5/小程序:依赖 video http-cache + 仅做预连接(不强制落盘) */ const INDEX_KEY = 'hl88app:feed_video_cache_index' const MAX_ENTRIES = 12 const MAX_BYTES = 120 * 1024 * 1024 const MAX_CONCURRENT = 2 /** @type {Map} */ const memory = new Map() /** @type {Map>} */ const inflight = new Map() /** @type {string[]} */ const waitQueue = [] let activeCount = 0 let indexLoaded = false function canPersistFiles() { // #ifdef APP-PLUS return true // #endif return false } function hashUrl(url) { let h = 0 const s = String(url) for (let i = 0; i < s.length; i++) { h = (h << 5) - h + s.charCodeAt(i) h |= 0 } return `v${Math.abs(h)}` } function loadIndex() { if (indexLoaded) return indexLoaded = true try { const raw = uni.getStorageSync(INDEX_KEY) if (!raw || typeof raw !== 'object') return Object.keys(raw).forEach((url) => { const row = raw[url] if (row && row.path) memory.set(url, row) }) } catch (_) {} } function persistIndex() { try { const payload = {} memory.forEach((row, url) => { payload[url] = row }) uni.setStorageSync(INDEX_KEY, payload) } catch (_) {} } function touch(url) { const row = memory.get(url) if (row) row.atime = Date.now() } function totalBytes() { let sum = 0 memory.forEach((row) => { sum += Number(row.size) || 0 }) return sum } function evictLru() { while (memory.size >= MAX_ENTRIES || totalBytes() > MAX_BYTES) { let oldestUrl = '' let oldestAt = Infinity memory.forEach((row, url) => { if (row.atime < oldestAt) { oldestAt = row.atime oldestUrl = url } }) if (!oldestUrl) break const victim = memory.get(oldestUrl) memory.delete(oldestUrl) if (victim && victim.path) { // #ifdef APP-PLUS uni.removeSavedFile({ filePath: victim.path, complete: () => {} }) // #endif } } persistIndex() } function pumpQueue() { if (activeCount >= MAX_CONCURRENT || !waitQueue.length) return const url = waitQueue.shift() if (!url || memory.has(url) || inflight.has(url)) { pumpQueue() return } activeCount += 1 const job = downloadAndSave(url) .then((local) => { if (local) { memory.set(url, { path: local, size: 0, atime: Date.now() }) evictLru() } return local || url }) .finally(() => { activeCount -= 1 inflight.delete(url) pumpQueue() }) inflight.set(url, job) } function downloadAndSave(url) { return new Promise((resolve) => { // #ifdef APP-PLUS uni.downloadFile({ url, success: (res) => { if (res.statusCode !== 200 || !res.tempFilePath) { resolve('') return } uni.saveFile({ tempFilePath: res.tempFilePath, success: (saved) => resolve(saved.savedFilePath || ''), fail: () => resolve('') }) }, fail: () => resolve('') }) // #endif // #ifndef APP-PLUS resolve('') // #endif }) } /** * 解析可播放地址:有本地缓存则返回 file:// 路径 * @param {string} remoteUrl */ export async function resolvePlaybackUrl(remoteUrl) { if (!remoteUrl) return '' loadIndex() if (!canPersistFiles()) return remoteUrl const cached = memory.get(remoteUrl) if (cached && cached.path) { touch(remoteUrl) return cached.path } if (inflight.has(remoteUrl)) { return inflight.get(remoteUrl) } const job = downloadAndSave(remoteUrl) .then((local) => { if (local) { uni.getSavedFileInfo({ filePath: local, success: (info) => { memory.set(remoteUrl, { path: local, size: info.size || 0, atime: Date.now() }) evictLru() }, fail: () => { memory.set(remoteUrl, { path: local, size: 0, atime: Date.now() }) evictLru() } }) return local } return remoteUrl }) .finally(() => inflight.delete(remoteUrl)) inflight.set(remoteUrl, job) return job } /** * 后台预取(不阻塞当前播放) * @param {string} remoteUrl * @param {'high'|'low'} [priority] */ export function prefetchPlaybackUrl(remoteUrl, priority = 'low') { if (!remoteUrl || !canPersistFiles()) return loadIndex() if (memory.has(remoteUrl) || inflight.has(remoteUrl)) return if (waitQueue.includes(remoteUrl)) return if (priority === 'high') waitQueue.unshift(remoteUrl) else waitQueue.push(remoteUrl) pumpQueue() } /** 按当前索引预取相邻视频 */ export function prefetchNeighbors(clips, centerIndex, toRemoteUrl) { if (!Array.isArray(clips) || !clips.length) return const order = [centerIndex + 1, centerIndex - 1, centerIndex + 2] order.forEach((idx, i) => { const clip = clips[idx] if (!clip) return const remote = toRemoteUrl(clip) if (!remote) return prefetchPlaybackUrl(remote, i === 0 ? 'high' : 'low') }) } export function clearVideoAssetCache() { memory.forEach((row) => { if (row.path) { // #ifdef APP-PLUS uni.removeSavedFile({ filePath: row.path, complete: () => {} }) // #endif } }) memory.clear() inflight.clear() waitQueue.length = 0 activeCount = 0 try { uni.removeStorageSync(INDEX_KEY) } catch (_) {} } export function readCachedPlaybackUrl(remoteUrl) { loadIndex() const row = memory.get(remoteUrl) return row && row.path ? row.path : '' } /** 本地文件损坏时剔除并回退远程播放 */ export function dropCachedPlaybackUrl(remoteUrl) { if (!remoteUrl) return const row = memory.get(remoteUrl) memory.delete(remoteUrl) inflight.delete(remoteUrl) const idx = waitQueue.indexOf(remoteUrl) if (idx >= 0) waitQueue.splice(idx, 1) if (row && row.path) { // #ifdef APP-PLUS uni.removeSavedFile({ filePath: row.path, complete: () => {} }) // #endif } persistIndex() }