ios fixed

This commit is contained in:
2026-09-12 14:01:20 +08:00
parent ab4ff6c25a
commit 8414f36cca
315 changed files with 7271 additions and 66 deletions

View File

@@ -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()
}