fixed some bugs
This commit is contained in:
@@ -34,6 +34,7 @@
|
|||||||
AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; };
|
AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; };
|
||||||
AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; };
|
AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.swift */; };
|
||||||
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000081 /* AudioRecorderSession.swift */; };
|
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000081 /* AudioRecorderSession.swift */; };
|
||||||
|
AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A6 /* StreamingVideoCache.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
|
AA0000000000000000000071 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
|
||||||
AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = "<group>"; };
|
AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = "<group>"; };
|
||||||
AA0000000000000000000081 /* AudioRecorderSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderSession.swift; sourceTree = "<group>"; };
|
AA0000000000000000000081 /* AudioRecorderSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderSession.swift; sourceTree = "<group>"; };
|
||||||
|
AA00000000000000000000A6 /* StreamingVideoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingVideoCache.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -132,6 +134,7 @@
|
|||||||
AA00000000000000000000A2 /* UserHistory.swift */,
|
AA00000000000000000000A2 /* UserHistory.swift */,
|
||||||
AA000000000000000000007E /* DubbingCompositor.swift */,
|
AA000000000000000000007E /* DubbingCompositor.swift */,
|
||||||
AA0000000000000000000081 /* AudioRecorderSession.swift */,
|
AA0000000000000000000081 /* AudioRecorderSession.swift */,
|
||||||
|
AA00000000000000000000A6 /* StreamingVideoCache.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
AA000000000000000000004B /* ImitationAssessor.swift in Sources */,
|
AA000000000000000000004B /* ImitationAssessor.swift in Sources */,
|
||||||
AA000000000000000000007D /* DubbingCompositor.swift in Sources */,
|
AA000000000000000000007D /* DubbingCompositor.swift in Sources */,
|
||||||
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */,
|
AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */,
|
||||||
|
AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */,
|
||||||
AA000000000000000000004C /* PlayerController.swift in Sources */,
|
AA000000000000000000004C /* PlayerController.swift in Sources */,
|
||||||
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */,
|
AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */,
|
||||||
AA000000000000000000004E /* Theme.swift in Sources */,
|
AA000000000000000000004E /* Theme.swift in Sources */,
|
||||||
|
|||||||
Binary file not shown.
@@ -20,12 +20,18 @@ final class PlayerController: ObservableObject {
|
|||||||
private var lastSentenceIndex: Int?
|
private var lastSentenceIndex: Int?
|
||||||
private var pendingSeekPositionMs: Int64?
|
private var pendingSeekPositionMs: Int64?
|
||||||
private var seekGeneration = 0
|
private var seekGeneration = 0
|
||||||
|
/// ExoPlayer 的 playWhenReady 等价物。AVPlayer 的 actionAtItemEnd 可能在
|
||||||
|
/// 句尾和切句 seek 的极短竞态里先自动暂停;这里不能依赖 player.rate 判断
|
||||||
|
/// 用户是否希望继续播放。
|
||||||
|
private var playbackIntent = false
|
||||||
private var stopAtSentenceEnd = false
|
private var stopAtSentenceEnd = false
|
||||||
private var stopAtSentenceEndIndex: Int?
|
private var stopAtSentenceEndIndex: Int?
|
||||||
private var swipeBackAnchorIndex: Int?
|
private var swipeBackAnchorIndex: Int?
|
||||||
private var swipeBackAnchorTime: TimeInterval = 0
|
private var swipeBackAnchorTime: TimeInterval = 0
|
||||||
private var speedBeforeLongPress: Float?
|
private var speedBeforeLongPress: Float?
|
||||||
private var transientTask: Task<Void, Never>?
|
private var transientTask: Task<Void, Never>?
|
||||||
|
private var cacheSwapTask: Task<Void, Never>?
|
||||||
|
private var activePlaybackURL: URL?
|
||||||
private var sentenceEndTimeObserver: Any?
|
private var sentenceEndTimeObserver: Any?
|
||||||
private var sentenceEndGuardToken = 0
|
private var sentenceEndGuardToken = 0
|
||||||
|
|
||||||
@@ -79,6 +85,7 @@ final class PlayerController: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - 装载
|
// MARK: - 装载
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func load(_ item: TrainingMediaItem) {
|
func load(_ item: TrainingMediaItem) {
|
||||||
cancelSentenceBoundaryGuard()
|
cancelSentenceBoundaryGuard()
|
||||||
lastSentenceIndex = nil
|
lastSentenceIndex = nil
|
||||||
@@ -89,18 +96,51 @@ final class PlayerController: ObservableObject {
|
|||||||
mediaItem = item
|
mediaItem = item
|
||||||
currentItem = item
|
currentItem = item
|
||||||
boundaryState = .idle
|
boundaryState = .idle
|
||||||
let playerItem = AVPlayerItem(url: item.url)
|
let playbackURL: URL
|
||||||
|
if item.isLocalFile {
|
||||||
|
playbackURL = item.url
|
||||||
|
} else if let cachedURL = StreamingVideoCache.shared.cachedFileIfAvailable(for: item.url) {
|
||||||
|
playbackURL = cachedURL
|
||||||
|
} else {
|
||||||
|
playbackURL = item.url
|
||||||
|
}
|
||||||
|
|
||||||
|
activePlaybackURL = playbackURL
|
||||||
|
let playerItem = AVPlayerItem(url: playbackURL)
|
||||||
attachItemObservers(to: playerItem)
|
attachItemObservers(to: playerItem)
|
||||||
player.replaceCurrentItem(with: playerItem)
|
player.replaceCurrentItem(with: playerItem)
|
||||||
if config.autoPlay {
|
if config.autoPlay {
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
|
playbackIntent = config.autoPlay
|
||||||
|
scheduleCachePrefetchIfNeeded(for: item, activeURL: playbackURL)
|
||||||
|
tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 句边界请求完成后只更新训练数据,不重新创建 AVPlayerItem。
|
||||||
|
/// 否则会把 AVFoundation 已经缓存的媒体数据丢掉,回跳上一句时可能重新联网。
|
||||||
|
@MainActor
|
||||||
|
func updateBoundaries(_ sentences: [SentenceBoundary]) {
|
||||||
|
guard let oldItem = mediaItem else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let newItem = TrainingMediaItem(
|
||||||
|
id: oldItem.id,
|
||||||
|
title: oldItem.title,
|
||||||
|
url: oldItem.url,
|
||||||
|
sentences: sentences,
|
||||||
|
isLocalFile: oldItem.isLocalFile
|
||||||
|
)
|
||||||
|
mediaItem = newItem
|
||||||
|
currentItem = newItem
|
||||||
|
lastSentenceIndex = nil
|
||||||
tick()
|
tick()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 播放控制
|
// MARK: - 播放控制
|
||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
|
playbackIntent = true
|
||||||
player.play()
|
player.play()
|
||||||
// play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用;
|
// play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用;
|
||||||
// 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。
|
// 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。
|
||||||
@@ -110,6 +150,7 @@ final class PlayerController: ObservableObject {
|
|||||||
func pause() {
|
func pause() {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
|
playbackIntent = false
|
||||||
cancelSentenceBoundaryGuard()
|
cancelSentenceBoundaryGuard()
|
||||||
player.pause()
|
player.pause()
|
||||||
}
|
}
|
||||||
@@ -129,6 +170,7 @@ final class PlayerController: ObservableObject {
|
|||||||
func stop() {
|
func stop() {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
|
playbackIntent = false
|
||||||
cancelSentenceBoundaryGuard()
|
cancelSentenceBoundaryGuard()
|
||||||
player.pause()
|
player.pause()
|
||||||
seek(to: .zero)
|
seek(to: .zero)
|
||||||
@@ -423,10 +465,52 @@ final class PlayerController: ObservableObject {
|
|||||||
pendingSeekPositionMs ?? currentPositionMs()
|
pendingSeekPositionMs ?? currentPositionMs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func scheduleCachePrefetchIfNeeded(for item: TrainingMediaItem, activeURL: URL) {
|
||||||
|
cacheSwapTask?.cancel()
|
||||||
|
cacheSwapTask = nil
|
||||||
|
guard !item.isLocalFile,
|
||||||
|
activeURL != item.url else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let remoteURL = item.url
|
||||||
|
cacheSwapTask = Task { [weak self] in
|
||||||
|
guard let cachedURL = await StreamingVideoCache.shared.prefetch(remoteURL) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self?.swapToCachedFileIfNeeded(remoteURL: remoteURL, cachedURL: cachedURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func swapToCachedFileIfNeeded(remoteURL: URL, cachedURL: URL) {
|
||||||
|
guard !Task.isCancelled,
|
||||||
|
mediaItem?.url == remoteURL,
|
||||||
|
activePlaybackURL != cachedURL else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetPositionMs = pendingSeekPositionMs ?? currentPositionMs()
|
||||||
|
let newItem = AVPlayerItem(url: cachedURL)
|
||||||
|
activePlaybackURL = cachedURL
|
||||||
|
|
||||||
|
// 递增 seek generation,让旧远程 Item 上未完成的 seek 回调失效。
|
||||||
|
seekGeneration += 1
|
||||||
|
attachItemObservers(to: newItem)
|
||||||
|
player.replaceCurrentItem(with: newItem)
|
||||||
|
performSeek(to: targetPositionMs)
|
||||||
|
}
|
||||||
|
|
||||||
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
|
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
|
||||||
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
|
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
|
||||||
private func performSeek(to positionMs: Int64, completion: (() -> Void)? = nil) {
|
private func performSeek(
|
||||||
|
to positionMs: Int64,
|
||||||
|
resumePlayback: Bool? = nil,
|
||||||
|
completion: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
let targetPositionMs = max(0, positionMs)
|
let targetPositionMs = max(0, positionMs)
|
||||||
|
let shouldResumePlayback = resumePlayback ?? playbackIntent
|
||||||
seekGeneration += 1
|
seekGeneration += 1
|
||||||
let generation = seekGeneration
|
let generation = seekGeneration
|
||||||
pendingSeekPositionMs = targetPositionMs
|
pendingSeekPositionMs = targetPositionMs
|
||||||
@@ -441,6 +525,9 @@ final class PlayerController: ObservableObject {
|
|||||||
}
|
}
|
||||||
self.pendingSeekPositionMs = nil
|
self.pendingSeekPositionMs = nil
|
||||||
self.tick()
|
self.tick()
|
||||||
|
if shouldResumePlayback {
|
||||||
|
self.player.play()
|
||||||
|
}
|
||||||
completion?()
|
completion?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,6 +715,7 @@ final class PlayerController: ObservableObject {
|
|||||||
if stopAtSentenceEnd, stopAtSentenceEndIndex == sentence.index {
|
if stopAtSentenceEnd, stopAtSentenceEndIndex == sentence.index {
|
||||||
stopAtSentenceEnd = false
|
stopAtSentenceEnd = false
|
||||||
stopAtSentenceEndIndex = nil
|
stopAtSentenceEndIndex = nil
|
||||||
|
playbackIntent = false
|
||||||
player.pause()
|
player.pause()
|
||||||
seekRaw(sentenceSeekPosition(sentence))
|
seekRaw(sentenceSeekPosition(sentence))
|
||||||
tick()
|
tick()
|
||||||
@@ -687,8 +775,9 @@ final class PlayerController: ObservableObject {
|
|||||||
tick()
|
tick()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 播放中自然结束时 playbackIntent 仍为 true;如果用户本来已暂停,
|
||||||
|
// 则不应因为到达 media end 而突然自动循环。
|
||||||
performSeek(to: .zero)
|
performSeek(to: .zero)
|
||||||
player.play()
|
|
||||||
tick()
|
tick()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
188
ios/OralTrainer/Services/StreamingVideoCache.swift
Normal file
188
ios/OralTrainer/Services/StreamingVideoCache.swift
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,13 +51,7 @@ final class CatalogViewModel: ObservableObject {
|
|||||||
guard activeVideoHash == video.videoHash else {
|
guard activeVideoHash == video.videoHash else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let item = TrainingMediaItem(
|
controller.updateBoundaries(document.sentences)
|
||||||
id: video.videoHash,
|
|
||||||
title: video.title,
|
|
||||||
url: url,
|
|
||||||
sentences: document.sentences
|
|
||||||
)
|
|
||||||
controller.load(item)
|
|
||||||
controller.boundaryState = .loaded(count: document.sentences.count)
|
controller.boundaryState = .loaded(count: document.sentences.count)
|
||||||
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
|
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
|
||||||
} catch {
|
} catch {
|
||||||
@@ -101,14 +95,7 @@ final class CatalogViewModel: ObservableObject {
|
|||||||
guard activeVideoHash == hash else {
|
guard activeVideoHash == hash else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let loadedItem = TrainingMediaItem(
|
controller.updateBoundaries(document.sentences)
|
||||||
id: hash,
|
|
||||||
title: fileName,
|
|
||||||
url: tempURL,
|
|
||||||
sentences: document.sentences,
|
|
||||||
isLocalFile: true
|
|
||||||
)
|
|
||||||
controller.load(loadedItem)
|
|
||||||
controller.boundaryState = .loaded(count: document.sentences.count)
|
controller.boundaryState = .loaded(count: document.sentences.count)
|
||||||
controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句")
|
controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句")
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user