ios fixed
This commit is contained in:
@@ -63,3 +63,22 @@ enum BoundaryState: Equatable {
|
||||
case loaded(count: Int)
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
/// 云端视频必须先完成整文件下载;下载未完成前不允许创建可播放的 AVPlayerItem。
|
||||
enum VideoCacheState: Equatable {
|
||||
case notRequired
|
||||
case downloading
|
||||
case ready
|
||||
case failed(message: String)
|
||||
|
||||
var statusMessage: String? {
|
||||
switch self {
|
||||
case .notRequired, .ready:
|
||||
return nil
|
||||
case .downloading:
|
||||
return "正在下载完整视频,下载完成后才能播放"
|
||||
case .failed(let message):
|
||||
return "视频缓存失败:\(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ final class PlayerController: ObservableObject {
|
||||
@Published private(set) var currentSentence: SentenceBoundary?
|
||||
@Published var boundaryState: BoundaryState = .idle
|
||||
@Published var transientMessage: String?
|
||||
@Published private(set) var cacheState: VideoCacheState = .notRequired
|
||||
|
||||
let player = AVPlayer()
|
||||
|
||||
@@ -39,6 +40,8 @@ final class PlayerController: ObservableObject {
|
||||
/// AVPlayer 在个别句子边界会把 seek 结果上报得比目标早 1ms 左右。
|
||||
/// 如果恰好落在句边界前,tick 会把句子重新判定回上一句;稍微进入目标句可消除该问题。
|
||||
private static let sentenceSeekInsetMs: Int64 = 20
|
||||
private static let previousSentenceToleranceMs: Int64 = 250
|
||||
private static let nextSentenceToleranceMs: Int64 = 150
|
||||
|
||||
private var timeObserver: Any?
|
||||
private var endObserver: NSObjectProtocol?
|
||||
@@ -88,33 +91,32 @@ final class PlayerController: ObservableObject {
|
||||
@MainActor
|
||||
func load(_ item: TrainingMediaItem) {
|
||||
cancelSentenceBoundaryGuard()
|
||||
cacheSwapTask?.cancel()
|
||||
cacheSwapTask = nil
|
||||
lastSentenceIndex = nil
|
||||
swipeBackAnchorIndex = nil
|
||||
pendingSeekPositionMs = nil
|
||||
stopAtSentenceEnd = false
|
||||
stopAtSentenceEndIndex = nil
|
||||
playbackIntent = false
|
||||
mediaItem = item
|
||||
currentItem = item
|
||||
boundaryState = .idle
|
||||
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()
|
||||
if item.isLocalFile {
|
||||
cacheState = .notRequired
|
||||
preparePlaybackItem(with: item.url)
|
||||
} else if let cachedURL = StreamingVideoCache.shared.cachedFileIfAvailable(for: item.url) {
|
||||
cacheState = .ready
|
||||
preparePlaybackItem(with: cachedURL)
|
||||
} else {
|
||||
// 关键约束:云端视频没有完整本地文件前,不把 remote URL 交给 AVPlayer。
|
||||
activePlaybackURL = nil
|
||||
cacheState = .downloading
|
||||
player.replaceCurrentItem(with: nil)
|
||||
tick()
|
||||
scheduleFullFileDownload(for: item)
|
||||
}
|
||||
playbackIntent = config.autoPlay
|
||||
scheduleCachePrefetchIfNeeded(for: item, activeURL: playbackURL)
|
||||
tick()
|
||||
}
|
||||
|
||||
/// 句边界请求完成后只更新训练数据,不重新创建 AVPlayerItem。
|
||||
@@ -201,8 +203,13 @@ final class PlayerController: ObservableObject {
|
||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||
return false
|
||||
}
|
||||
guard let currentIndex = sentenceAnchorIndex() else { return false }
|
||||
let target = item.sentences[max(0, currentIndex - 1)]
|
||||
// 用播放位置查找目标,而不是用可能已经过期的 sentence index 推导。
|
||||
// 播放点落在两句之间的空隙时也能正确定位前一句。
|
||||
let position = referencePositionMs()
|
||||
let target = item.sentences
|
||||
.reversed()
|
||||
.first { $0.startMs < position - Self.previousSentenceToleranceMs }
|
||||
?? item.sentences[0]
|
||||
seekToSentenceBeginning(target)
|
||||
return true
|
||||
}
|
||||
@@ -212,12 +219,14 @@ final class PlayerController: ObservableObject {
|
||||
guard let item = mediaItem, !item.sentences.isEmpty else {
|
||||
return false
|
||||
}
|
||||
guard let currentIndex = sentenceAnchorIndex() else { return false }
|
||||
let targetIndex = currentIndex + 1
|
||||
guard item.sentences.indices.contains(targetIndex) else {
|
||||
// Android 端同样按当前位置向后查找下一个 startMs。即使边界表存在句间
|
||||
// 空隙,或 AVPlayer 的时间回调仍停留在上一句,也能跳到实际的下一句。
|
||||
let position = referencePositionMs()
|
||||
guard let target = item.sentences.first(where: {
|
||||
$0.startMs > position + Self.nextSentenceToleranceMs
|
||||
}) else {
|
||||
return false
|
||||
}
|
||||
let target = item.sentences[targetIndex]
|
||||
seekToSentenceBeginning(target)
|
||||
return true
|
||||
}
|
||||
@@ -466,40 +475,47 @@ final class PlayerController: ObservableObject {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleCachePrefetchIfNeeded(for item: TrainingMediaItem, activeURL: URL) {
|
||||
cacheSwapTask?.cancel()
|
||||
cacheSwapTask = nil
|
||||
guard !item.isLocalFile,
|
||||
activeURL != item.url else {
|
||||
return
|
||||
}
|
||||
|
||||
private func scheduleFullFileDownload(for item: TrainingMediaItem) {
|
||||
let remoteURL = item.url
|
||||
cacheSwapTask = Task { [weak self] in
|
||||
guard let cachedURL = await StreamingVideoCache.shared.prefetch(remoteURL) else {
|
||||
let cachedURL: URL
|
||||
do {
|
||||
cachedURL = try await StreamingVideoCache.shared.prefetch(remoteURL)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
self?.cacheState = .failed(message: error.localizedDescription)
|
||||
self?.showTransientMessage("视频缓存失败:\(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
self?.swapToCachedFileIfNeeded(remoteURL: remoteURL, cachedURL: cachedURL)
|
||||
self?.prepareDownloadedFileIfNeeded(remoteURL: remoteURL, cachedURL: cachedURL)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func swapToCachedFileIfNeeded(remoteURL: URL, cachedURL: URL) {
|
||||
private func prepareDownloadedFileIfNeeded(remoteURL: URL, cachedURL: URL) {
|
||||
guard !Task.isCancelled,
|
||||
mediaItem?.url == remoteURL,
|
||||
activePlaybackURL != cachedURL else {
|
||||
activePlaybackURL == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
let targetPositionMs = pendingSeekPositionMs ?? currentPositionMs()
|
||||
let newItem = AVPlayerItem(url: cachedURL)
|
||||
activePlaybackURL = cachedURL
|
||||
cacheState = .ready
|
||||
preparePlaybackItem(with: cachedURL)
|
||||
showTransientMessage("视频已完整缓存,可以播放")
|
||||
}
|
||||
|
||||
// 递增 seek generation,让旧远程 Item 上未完成的 seek 回调失效。
|
||||
seekGeneration += 1
|
||||
private func preparePlaybackItem(with playbackURL: URL) {
|
||||
let newItem = AVPlayerItem(url: playbackURL)
|
||||
activePlaybackURL = playbackURL
|
||||
attachItemObservers(to: newItem)
|
||||
player.replaceCurrentItem(with: newItem)
|
||||
performSeek(to: targetPositionMs)
|
||||
let shouldPlay = config.autoPlay || playbackIntent
|
||||
if shouldPlay {
|
||||
player.play()
|
||||
}
|
||||
playbackIntent = shouldPlay
|
||||
tick()
|
||||
}
|
||||
|
||||
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
|
||||
@@ -511,6 +527,11 @@ final class PlayerController: ObservableObject {
|
||||
) {
|
||||
let targetPositionMs = max(0, positionMs)
|
||||
let shouldResumePlayback = resumePlayback ?? playbackIntent
|
||||
// 必须在发起 seek 前清掉上一句的硬边界。否则从第 1 句右划到第 2 句时,
|
||||
// AVPlayerItem 上的 forwardPlaybackEndTime 仍是第 1 句 endMs;seek 可能
|
||||
// 被这个旧边界截断,UI 先显示第 2 句,然后立刻又回到第 1 句。
|
||||
cancelSentenceBoundaryGuard()
|
||||
clearForwardPlaybackEnd()
|
||||
seekGeneration += 1
|
||||
let generation = seekGeneration
|
||||
pendingSeekPositionMs = targetPositionMs
|
||||
@@ -529,9 +550,11 @@ final class PlayerController: ObservableObject {
|
||||
self.player.play()
|
||||
}
|
||||
completion?()
|
||||
// seek 已真正落在目标句后,再按目标句注册新的句尾硬边界。
|
||||
self.refreshSentenceBoundaryGuard(force: shouldResumePlayback)
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
refreshSentenceBoundaryGuard()
|
||||
tick()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -184,6 +184,9 @@ struct FullscreenPlayerView: View {
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let message = controller.cacheState.statusMessage {
|
||||
return message
|
||||
}
|
||||
if let message = controller.transientMessage {
|
||||
return message
|
||||
}
|
||||
|
||||
@@ -202,6 +202,9 @@ struct PlayerSectionView: View {
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let message = controller.cacheState.statusMessage {
|
||||
return message
|
||||
}
|
||||
if let message = controller.transientMessage {
|
||||
return message
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user