ios fixed
This commit is contained in:
@@ -3,10 +3,9 @@ import Foundation
|
||||
|
||||
/// iOS 端的持久化整文件视频缓存。
|
||||
///
|
||||
/// Android 端使用 ExoPlayer 的 CacheDataSource 边播边缓存;AVFoundation 没有公开
|
||||
/// 等价的持久字节缓存 API。这里在后台把远程 MP4 下载成完整本地文件,下次装载时
|
||||
/// 直接交给 AVPlayer,并在本次播放完成下载后热切换到本地文件,避免回跳句子时
|
||||
/// 再次依赖网络。
|
||||
/// AVFoundation 没有公开可持久化的媒体字节缓存 API。这里先把远程视频完整
|
||||
/// 下载成原子本地文件;只有文件
|
||||
/// 完整落盘后才返回给播放器,避免用部分响应或播放列表创建可播放的 AVPlayerItem。
|
||||
@MainActor
|
||||
final class StreamingVideoCache {
|
||||
static let shared = StreamingVideoCache()
|
||||
@@ -18,7 +17,7 @@ final class StreamingVideoCache {
|
||||
|
||||
private let directory: URL
|
||||
private let fileManager = FileManager.default
|
||||
private var downloadsByURL: [URL: Task<URL?, Never>] = [:]
|
||||
private var downloadsByURL: [URL: Task<URL, Error>] = [:]
|
||||
|
||||
private init() {
|
||||
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
@@ -44,31 +43,40 @@ final class StreamingVideoCache {
|
||||
return file
|
||||
}
|
||||
|
||||
/// 重复调用同一 URL 时复用同一个后台下载;完成后返回本地文件。
|
||||
func prefetch(_ remoteURL: URL) async -> URL? {
|
||||
/// 重复调用同一 URL 时复用同一个后台下载;只有下载完成后才返回本地文件。
|
||||
func prefetch(_ remoteURL: URL) async throws -> URL {
|
||||
if let cached = cachedFileIfAvailable(for: remoteURL) {
|
||||
return cached
|
||||
}
|
||||
guard isCacheable(remoteURL), let destination = destinationFile(for: remoteURL) else {
|
||||
return nil
|
||||
guard let destination = destinationFile(for: remoteURL) else {
|
||||
throw StreamingVideoCacheError.unsupportedURL
|
||||
}
|
||||
guard isCacheable(remoteURL) else {
|
||||
throw StreamingVideoCacheError.unsupportedURL
|
||||
}
|
||||
|
||||
if let existing = downloadsByURL[remoteURL] {
|
||||
return await existing.value
|
||||
return try await existing.value
|
||||
}
|
||||
|
||||
let task = Task {
|
||||
await Self.download(from: remoteURL, to: destination)
|
||||
try await Self.download(from: remoteURL, to: destination)
|
||||
}
|
||||
downloadsByURL[remoteURL] = task
|
||||
|
||||
let result = await task.value
|
||||
let result: URL
|
||||
do {
|
||||
result = try await task.value
|
||||
} catch {
|
||||
if !task.isCancelled {
|
||||
downloadsByURL[remoteURL] = nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if downloadsByURL[remoteURL]?.isCancelled != true {
|
||||
downloadsByURL[remoteURL] = nil
|
||||
}
|
||||
if result != nil {
|
||||
evictIfNeeded(keeping: remoteURL)
|
||||
}
|
||||
evictIfNeeded(keeping: remoteURL)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -137,19 +145,17 @@ final class StreamingVideoCache {
|
||||
}
|
||||
}
|
||||
|
||||
private static func download(from remoteURL: URL, to destination: URL) async -> URL? {
|
||||
private static func download(from remoteURL: URL, to destination: URL) async throws -> URL {
|
||||
var request = URLRequest(url: remoteURL)
|
||||
request.timeoutInterval = AppConfig.connectTimeout
|
||||
request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (tempFile, response) = try await URLSession.shared.download(for: request)
|
||||
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
|
||||
throw StreamingVideoCacheError.downloadFailed(message: "HTTP \(status)")
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
@@ -160,11 +166,16 @@ final class StreamingVideoCache {
|
||||
)
|
||||
try fileManager.moveItem(at: tempFile, to: destination)
|
||||
return destination
|
||||
} catch is CancellationError {
|
||||
return nil
|
||||
} catch {
|
||||
FileManager.default.removeItemIfPossible(at: tempFile)
|
||||
if let cacheError = error as? StreamingVideoCacheError {
|
||||
logFailure(remoteURL, message: cacheError.localizedDescription)
|
||||
throw cacheError
|
||||
}
|
||||
logFailure(remoteURL, message: error.localizedDescription)
|
||||
return nil
|
||||
throw StreamingVideoCacheError.downloadFailed(
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +186,20 @@ final class StreamingVideoCache {
|
||||
}
|
||||
}
|
||||
|
||||
enum StreamingVideoCacheError: LocalizedError {
|
||||
case unsupportedURL
|
||||
case downloadFailed(message: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedURL:
|
||||
return "仅支持 MP4/M4V/MOV 整文件缓存"
|
||||
case .downloadFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FileManager {
|
||||
@discardableResult
|
||||
func removeItemIfPossible(at url: URL) -> Bool {
|
||||
|
||||
Reference in New Issue
Block a user