diff --git a/ios/OralTrainer.xcodeproj/project.pbxproj b/ios/OralTrainer.xcodeproj/project.pbxproj index d07f68b..33d9a50 100644 --- a/ios/OralTrainer.xcodeproj/project.pbxproj +++ b/ios/OralTrainer.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ AA0000000000000000000060 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000070 /* InfoPlist.strings */; }; AA000000000000000000007D /* DubbingCompositor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000000000000000000007E /* DubbingCompositor.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 */ /* 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 = ""; }; AA000000000000000000007E /* DubbingCompositor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DubbingCompositor.swift; sourceTree = ""; }; AA0000000000000000000081 /* AudioRecorderSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderSession.swift; sourceTree = ""; }; + AA00000000000000000000A6 /* StreamingVideoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingVideoCache.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -132,6 +134,7 @@ AA00000000000000000000A2 /* UserHistory.swift */, AA000000000000000000007E /* DubbingCompositor.swift */, AA0000000000000000000081 /* AudioRecorderSession.swift */, + AA00000000000000000000A6 /* StreamingVideoCache.swift */, ); path = Services; sourceTree = ""; @@ -253,6 +256,7 @@ AA000000000000000000004B /* ImitationAssessor.swift in Sources */, AA000000000000000000007D /* DubbingCompositor.swift in Sources */, AA0000000000000000000080 /* AudioRecorderSession.swift in Sources */, + AA00000000000000000000A5 /* StreamingVideoCache.swift in Sources */, AA000000000000000000004C /* PlayerController.swift in Sources */, AA000000000000000000004D /* PlayerSurfaceView.swift in Sources */, AA000000000000000000004E /* Theme.swift in Sources */, diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 2043b23..80f746c 100644 Binary files a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OralTrainer/Player/PlayerController.swift b/ios/OralTrainer/Player/PlayerController.swift index 8b9d87c..c4e1008 100644 --- a/ios/OralTrainer/Player/PlayerController.swift +++ b/ios/OralTrainer/Player/PlayerController.swift @@ -20,12 +20,18 @@ final class PlayerController: ObservableObject { private var lastSentenceIndex: Int? private var pendingSeekPositionMs: Int64? private var seekGeneration = 0 + /// ExoPlayer 的 playWhenReady 等价物。AVPlayer 的 actionAtItemEnd 可能在 + /// 句尾和切句 seek 的极短竞态里先自动暂停;这里不能依赖 player.rate 判断 + /// 用户是否希望继续播放。 + private var playbackIntent = false private var stopAtSentenceEnd = false private var stopAtSentenceEndIndex: Int? private var swipeBackAnchorIndex: Int? private var swipeBackAnchorTime: TimeInterval = 0 private var speedBeforeLongPress: Float? private var transientTask: Task? + private var cacheSwapTask: Task? + private var activePlaybackURL: URL? private var sentenceEndTimeObserver: Any? private var sentenceEndGuardToken = 0 @@ -79,6 +85,7 @@ final class PlayerController: ObservableObject { // MARK: - 装载 + @MainActor func load(_ item: TrainingMediaItem) { cancelSentenceBoundaryGuard() lastSentenceIndex = nil @@ -89,18 +96,51 @@ final class PlayerController: ObservableObject { mediaItem = item currentItem = item 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) player.replaceCurrentItem(with: playerItem) if config.autoPlay { 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() } // MARK: - 播放控制 func play() { + playbackIntent = true player.play() // play() 可能在 AVPlayer 还没有把 timeControlStatus 切到 playing 前被调用; // 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。 @@ -110,6 +150,7 @@ final class PlayerController: ObservableObject { func pause() { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil + playbackIntent = false cancelSentenceBoundaryGuard() player.pause() } @@ -129,6 +170,7 @@ final class PlayerController: ObservableObject { func stop() { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil + playbackIntent = false cancelSentenceBoundaryGuard() player.pause() seek(to: .zero) @@ -423,10 +465,52 @@ final class PlayerController: ObservableObject { 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 必须统一走这里,避免播放循环、 /// 用户手势和 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 shouldResumePlayback = resumePlayback ?? playbackIntent seekGeneration += 1 let generation = seekGeneration pendingSeekPositionMs = targetPositionMs @@ -441,6 +525,9 @@ final class PlayerController: ObservableObject { } self.pendingSeekPositionMs = nil self.tick() + if shouldResumePlayback { + self.player.play() + } completion?() } } @@ -628,6 +715,7 @@ final class PlayerController: ObservableObject { if stopAtSentenceEnd, stopAtSentenceEndIndex == sentence.index { stopAtSentenceEnd = false stopAtSentenceEndIndex = nil + playbackIntent = false player.pause() seekRaw(sentenceSeekPosition(sentence)) tick() @@ -687,8 +775,9 @@ final class PlayerController: ObservableObject { tick() return } + // 播放中自然结束时 playbackIntent 仍为 true;如果用户本来已暂停, + // 则不应因为到达 media end 而突然自动循环。 performSeek(to: .zero) - player.play() tick() } diff --git a/ios/OralTrainer/Services/StreamingVideoCache.swift b/ios/OralTrainer/Services/StreamingVideoCache.swift new file mode 100644 index 0000000..aae5b55 --- /dev/null +++ b/ios/OralTrainer/Services/StreamingVideoCache.swift @@ -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 = ["mp4", "m4v", "mov"] + private static let fileExtension = "mp4" + + private let directory: URL + private let fileManager = FileManager.default + private var downloadsByURL: [URL: Task] = [:] + + 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 + } + } +} diff --git a/ios/OralTrainer/Views/CatalogView.swift b/ios/OralTrainer/Views/CatalogView.swift index 8bbf0e5..542b654 100644 --- a/ios/OralTrainer/Views/CatalogView.swift +++ b/ios/OralTrainer/Views/CatalogView.swift @@ -51,13 +51,7 @@ final class CatalogViewModel: ObservableObject { guard activeVideoHash == video.videoHash else { return } - let item = TrainingMediaItem( - id: video.videoHash, - title: video.title, - url: url, - sentences: document.sentences - ) - controller.load(item) + controller.updateBoundaries(document.sentences) controller.boundaryState = .loaded(count: document.sentences.count) controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句") } catch { @@ -101,14 +95,7 @@ final class CatalogViewModel: ObservableObject { guard activeVideoHash == hash else { return } - let loadedItem = TrainingMediaItem( - id: hash, - title: fileName, - url: tempURL, - sentences: document.sentences, - isLocalFile: true - ) - controller.load(loadedItem) + controller.updateBoundaries(document.sentences) controller.boundaryState = .loaded(count: document.sentences.count) controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句") } catch {