Files
mediaplayer/ios/OralTrainer/Services/StreamingVideoCache.swift
2026-09-10 08:28:07 +08:00

189 lines
6.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CryptoKit
import Foundation
/// iOS 端的持久化整文件视频缓存。
///
/// Android 端使用 ExoPlayer 的 CacheDataSource 边播边缓存;AVFoundation 没有公开
/// 等价的持久字节缓存 API。这里在后台把远程 MP4 下载成完整本地文件,下次装载时
/// 直接交给 AVPlayer,并在本次播放完成下载后热切换到本地文件,避免回跳句子时
/// 再次依赖网络。
@MainActor
final class StreamingVideoCache {
static let shared = StreamingVideoCache()
static let maxCacheBytes: Int64 = 512 * 1024 * 1024
private static let supportedExtensions: Set<String> = ["mp4", "m4v", "mov"]
private static let fileExtension = "mp4"
private let directory: URL
private let fileManager = FileManager.default
private var downloadsByURL: [URL: Task<URL?, Never>] = [:]
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
directory = caches.appendingPathComponent("oral-trainer-streaming-videos", isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
func cachedFileIfAvailable(for remoteURL: URL) -> URL? {
guard isCacheable(remoteURL), let file = destinationFile(for: remoteURL) else {
return nil
}
guard fileManager.fileExists(atPath: file.path), isUsableMediaFile(file) else {
fileManager.removeItemIfPossible(at: file)
return nil
}
// AVPlayer 读取后刷新 LRU 时间,与 Android LeastRecentlyUsedCacheEvictor 的
// 语义保持接近。
try? fileManager.setAttributes(
[.modificationDate: Date()],
ofItemAtPath: file.path
)
return file
}
/// 重复调用同一 URL 时复用同一个后台下载;完成后返回本地文件。
func prefetch(_ remoteURL: URL) async -> URL? {
if let cached = cachedFileIfAvailable(for: remoteURL) {
return cached
}
guard isCacheable(remoteURL), let destination = destinationFile(for: remoteURL) else {
return nil
}
if let existing = downloadsByURL[remoteURL] {
return await existing.value
}
let task = Task {
await Self.download(from: remoteURL, to: destination)
}
downloadsByURL[remoteURL] = task
let result = await task.value
if downloadsByURL[remoteURL]?.isCancelled != true {
downloadsByURL[remoteURL] = nil
}
if result != nil {
evictIfNeeded(keeping: remoteURL)
}
return result
}
func clear() {
for task in downloadsByURL.values {
task.cancel()
}
downloadsByURL.removeAll()
try? fileManager.removeItem(at: directory)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
private func isCacheable(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return false
}
let ext = url.pathExtension.lowercased()
// HLS/DASH 播放列表不能通过下载单个清单完成离线缓存;当前云端课程均为 MP4。
return Self.supportedExtensions.contains(ext)
}
private func destinationFile(for remoteURL: URL) -> URL? {
let rawData = Data(remoteURL.absoluteString.utf8)
let digest = SHA256.hash(data: rawData)
let key = digest.map { String(format: "%02x", $0) }.joined()
return directory.appendingPathComponent(key).appendingPathExtension(Self.fileExtension)
}
private func isUsableMediaFile(_ url: URL) -> Bool {
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
guard values?.isRegularFile == true, let fileSize = values?.fileSize else {
return false
}
let size = Int64(fileSize)
return size > 0
}
private func evictIfNeeded(keeping remoteURL: URL) {
guard let keeping = destinationFile(for: remoteURL) else {
return
}
let contents = (try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
options: [.skipsHiddenFiles]
)) ?? []
var entries: [(url: URL, size: Int64, date: Date)] = contents.compactMap { url in
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
guard let fileSize = values?.fileSize, fileSize > 0 else {
fileManager.removeItemIfPossible(at: url)
return nil
}
let size = Int64(fileSize)
return (url, size, values?.contentModificationDate ?? .distantPast)
}
var totalBytes = entries.reduce(0) { $0 + $1.size }
entries.sort { $0.date < $1.date }
for entry in entries where totalBytes > Self.maxCacheBytes && entry.url != keeping {
guard fileManager.removeItemIfPossible(at: entry.url) else {
continue
}
totalBytes -= entry.size
}
}
private static func download(from remoteURL: URL, to destination: URL) async -> URL? {
var request = URLRequest(url: remoteURL)
request.timeoutInterval = AppConfig.connectTimeout
request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent")
request.cachePolicy = .reloadIgnoringLocalCacheData
do {
let (tempFile, response) = try await URLSession.shared.download(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
FileManager.default.removeItemIfPossible(at: tempFile)
logFailure(remoteURL, message: "HTTP \(status)")
return nil
}
let fileManager = FileManager.default
try? fileManager.removeItem(at: destination)
try fileManager.createDirectory(
at: destination.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try fileManager.moveItem(at: tempFile, to: destination)
return destination
} catch is CancellationError {
return nil
} catch {
logFailure(remoteURL, message: error.localizedDescription)
return nil
}
}
private static func logFailure(_ url: URL, message: String) {
#if DEBUG
print("[StreamingVideoCache] 预缓存失败: \(url), \(message)")
#endif
}
}
private extension FileManager {
@discardableResult
func removeItemIfPossible(at url: URL) -> Bool {
do {
try removeItem(at: url)
return true
} catch {
return false
}
}
}