Files
mediaplayer/ios/OralTrainer/Player/PlayerController.swift
2026-09-13 12:58:45 +08:00

884 lines
31 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AVFoundation
import Combine
import Foundation
/// 与安卓端 OralTrainerController 对齐的播放控制器。
/// 所有属性变更都发生在主线程(时间观察器、KVO、UI 回调均为主线程)。
final class PlayerController: ObservableObject {
@Published private(set) var snapshot = PlaybackSnapshot()
@Published private(set) var currentItem: TrainingMediaItem?
@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()
var config = PlayerConfig()
var loopMode: LoopMode = .all
private var mediaItem: TrainingMediaItem?
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<Void, Never>?
private var cacheFallbackTask: Task<Void, Never>?
private var activePlaybackURL: URL?
private var didAttemptFullFileFallback = false
private var sentenceEndTimeObserver: Any?
private var sentenceEndGuardToken = 0
private static let swipeBackPhaseTimeout: TimeInterval = 1.2
/// 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?
private var itemStatusObserver: NSKeyValueObservation?
private var timeControlObserver: NSKeyValueObservation?
init() {
configureAudioSession()
player.automaticallyWaitsToMinimizeStalling = true
player.actionAtItemEnd = .pause
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main
) { [weak self] _ in
self?.tick()
}
endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleEnded()
}
timeControlObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] _, _ in
DispatchQueue.main.async {
self?.refreshSentenceBoundaryGuard()
self?.tick()
}
}
tick()
}
deinit {
if let timeObserver = timeObserver {
player.removeTimeObserver(timeObserver)
}
if let endObserver = endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
cancelSentenceBoundaryGuard()
itemStatusObserver?.invalidate()
timeControlObserver?.invalidate()
}
// MARK: - 装载
@MainActor
func load(_ item: TrainingMediaItem) {
cancelSentenceBoundaryGuard()
cacheFallbackTask?.cancel()
cacheFallbackTask = nil
didAttemptFullFileFallback = false
lastSentenceIndex = nil
swipeBackAnchorIndex = nil
pendingSeekPositionMs = nil
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
playbackIntent = false
mediaItem = item
currentItem = item
boundaryState = .idle
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 {
cacheState = .notRequired
preparePlaybackItem(with: item.url)
}
}
/// 句边界请求完成后只更新训练数据,不重新创建 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 前被调用;
// 立即强制注册一次,避免最初的几十毫秒没有被句尾守卫覆盖。
refreshSentenceBoundaryGuard(force: true)
}
func pause() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
playbackIntent = false
cancelSentenceBoundaryGuard()
player.pause()
}
func setVolume(_ volume: Float) {
player.volume = volume
}
func togglePlayPause() {
if player.timeControlStatus == .playing {
pause()
} else {
play()
}
}
func stop() {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
playbackIntent = false
cancelSentenceBoundaryGuard()
player.pause()
seek(to: .zero)
}
func seek(to positionMs: Int64) {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
performSeek(to: positionMs)
}
func seekRelative(_ deltaMs: Int64) {
seek(to: referencePositionMs() + deltaMs)
}
func rewind() {
seekRelative(-max(0, config.defaultSeekStepMs))
}
func fastForward() {
seekRelative(max(0, config.defaultSeekStepMs))
}
// MARK: - 按句跳转
@discardableResult
func seekToPreviousSentence() -> Bool {
guard let item = mediaItem, !item.sentences.isEmpty else {
return false
}
// 用播放位置查找目标,而不是用可能已经过期的 sentence index 推导。
// 播放点落在两句之间的空隙时也能正确定位前一句。
let position = referencePositionMs()
let target = item.sentences
.reversed()
.first { $0.startMs < position - Self.previousSentenceToleranceMs }
?? item.sentences[0]
seekToSentenceBeginning(target)
return true
}
@discardableResult
func seekToNextSentence() -> Bool {
guard let item = mediaItem, !item.sentences.isEmpty else {
return false
}
// Android 端同样按当前位置向后查找下一个 startMs。即使边界表存在句间
// 空隙,或 AVPlayer 的时间回调仍停留在上一句,也能跳到实际的下一句。
let position = referencePositionMs()
guard let target = item.sentences.first(where: {
$0.startMs > position + Self.nextSentenceToleranceMs
}) else {
return false
}
seekToSentenceBeginning(target)
return true
}
/// 按索引跳转,而不是每次用异步的 AVPlayer 时间重新搜索。
/// 这样一次手势只前进/后退一句,也不会因 seek 后时间上报偏差导致重复或无效。
@discardableResult
func seekToSentence(at index: Int) -> Bool {
guard let item = mediaItem, item.sentences.indices.contains(index) else {
return false
}
seekToSentenceBeginning(item.sentences[index])
return true
}
private func sentenceAnchorIndex() -> Int? {
guard let item = mediaItem, !item.sentences.isEmpty else { return nil }
if let index = currentSentence?.index, item.sentences.indices.contains(index) {
return index
}
if let index = lastSentenceIndex, item.sentences.indices.contains(index) {
return index
}
let position = referencePositionMs()
if let sentence = sentence(at: position) {
return sentence.index
}
// 句间隙中时,取前一句作为锚点;在第一句之前时取下一句。
if let previous = item.sentences.last(where: { $0.startMs <= position }) {
return previous.index
}
return item.sentences.first?.index
}
func previousSentenceOrRewind() {
if !config.sentenceMode || !seekToPreviousSentence() {
rewind()
}
}
func nextSentenceOrForward() {
if !config.sentenceMode || !seekToNextSentence() {
fastForward()
}
}
/// 播放中向左划:第一次回到当前句开头重播;短时间内再划一次才到上一句。
/// 暂停时保持原来的“上一句”语义,测试页“上一句”按钮也不受影响。
@discardableResult
func replayOrPreviousSentence() -> SwipeBackResult {
guard config.sentenceMode,
let item = mediaItem,
!item.sentences.isEmpty,
let anchorIndex = sentenceAnchorIndex() else {
rewind()
return .rewound
}
let isActivelyPlaying = player.timeControlStatus == .playing
|| player.timeControlStatus == .waitingToPlayAtSpecifiedRate
guard isActivelyPlaying else {
swipeBackAnchorIndex = nil
return seekToPreviousSentence() ? .movedPrevious : .stayedAtFirst
}
let now = Date().timeIntervalSinceReferenceDate
if swipeBackAnchorIndex == anchorIndex,
now - swipeBackAnchorTime <= Self.swipeBackPhaseTimeout {
swipeBackAnchorIndex = nil
guard anchorIndex > 0 else {
seekToSentenceBeginning(item.sentences[anchorIndex])
return .stayedAtFirst
}
seekToSentenceBeginning(item.sentences[anchorIndex - 1])
return .movedPrevious
}
swipeBackAnchorIndex = anchorIndex
swipeBackAnchorTime = now
let current = item.sentences[anchorIndex]
if stopAtSentenceEnd {
playCurrentSentenceAndStop()
} else {
seekToSentenceBeginning(current)
play()
}
return .restartedCurrent
}
/// 只播放当前句子,播完自动暂停并回到句首(评测“播放原句”)。
/// 必须先打上句尾标记再 seek,这样 completion 里的 forwardPlaybackEndTime
/// 会卡在当前句 endMs,而不是等 250ms 观察器才停。
func playCurrentSentenceAndStop() {
guard let sentence = currentSentence else {
return
}
stopAtSentenceEnd = true
stopAtSentenceEndIndex = sentence.index
lastSentenceIndex = sentence.index
playbackIntent = true
performSeek(to: sentenceSeekPosition(sentence), resumePlayback: true)
}
// MARK: - 设置
func setContinuousPlayback(_ enabled: Bool) {
config.continuousPlayback = enabled
if !enabled {
lastSentenceIndex = currentSentence?.index ?? lastSentenceIndex
}
refreshSentenceBoundaryGuard(force: true)
}
func setSentenceMode(_ enabled: Bool) {
config.sentenceMode = enabled
refreshSentenceBoundaryGuard()
}
func setLoopMode(_ mode: LoopMode) {
loopMode = mode
}
func setPlaybackSpeed(_ speed: Float) {
let clamped = min(config.maxPlaybackSpeed, max(config.minPlaybackSpeed, speed))
if player.timeControlStatus == .playing {
player.rate = clamped
} else {
player.defaultRate = clamped
}
tick()
}
// MARK: - 长按变速(与安卓端 GestureControlsConfig 一致)
func beginLongPressSpeed(atX x: CGFloat, width: CGFloat) {
speedBeforeLongPress = snapshot.speed
setPlaybackSpeed(speedForTouchX(x, width: width))
showTransientMessage("变速播放中")
}
func updateLongPressSpeed(atX x: CGFloat, width: CGFloat) {
setPlaybackSpeed(speedForTouchX(x, width: width))
}
func endLongPressSpeed() {
if let speed = speedBeforeLongPress {
setPlaybackSpeed(speed)
}
speedBeforeLongPress = nil
}
private func speedForTouchX(_ x: CGFloat, width: CGFloat) -> Float {
guard width > 0 else {
return 1
}
let center = width / 2
let distanceRatio = abs(x - center) / center
let farFromCenter = distanceRatio >= 0.5
if x < center {
return farFromCenter ? 0.5 : 0.75
}
return farFromCenter ? 2.0 : 1.5
}
// MARK: - 手势消息
func handleGesture(_ kind: GestureKind) {
switch kind {
case .singleTap:
togglePlayPause()
showTransientMessage("播放状态已切换")
case .swipeLeft:
switch replayOrPreviousSentence() {
case .restartedCurrent:
showTransientMessage("已回到本句开头,再左划返回上一句")
case .movedPrevious:
showTransientMessage("已跳到上一句")
case .stayedAtFirst:
showTransientMessage("已是第一句")
case .rewound:
showTransientMessage("已快退")
}
case .swipeRight:
nextSentenceOrForward()
showTransientMessage("已跳到下一句")
case .longPressSpeed:
break
}
}
func showTransientMessage(_ message: String) {
transientMessage = message
transientTask?.cancel()
transientTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 1_500_000_000)
guard !Task.isCancelled else {
return
}
self?.transientMessage = nil
}
}
// MARK: - 状态刷新
private func tick() {
guard let item = player.currentItem else {
snapshot = PlaybackSnapshot(mediaId: mediaItem?.id, state: .idle, speed: currentSpeed())
return
}
let position = referencePositionMs()
if pendingSeekPositionMs == nil, lastSentenceIndex == nil {
lastSentenceIndex = sentence(at: position)?.index
}
if pendingSeekPositionMs == nil {
enforceSentenceBoundary(positionMs: position)
}
let finalPosition = referencePositionMs()
let sentence = sentence(at: finalPosition)
if sentence?.index != lastSentenceIndex {
lastSentenceIndex = sentence?.index
}
if sentence?.index != swipeBackAnchorIndex {
swipeBackAnchorIndex = nil
}
let duration = durationMs(of: item)
let state = playbackState(of: item, positionMs: finalPosition, durationMs: duration)
snapshot = PlaybackSnapshot(
mediaId: mediaItem?.id,
positionMs: finalPosition,
durationMs: duration,
bufferedPositionMs: bufferedPositionMs(of: item),
isPlaying: player.timeControlStatus == .playing,
state: state,
speed: currentSpeed(),
sentenceIndex: sentence?.index
)
currentSentence = sentence
}
private func currentPositionMs() -> Int64 {
let seconds = player.currentTime().seconds
guard seconds.isFinite else {
return 0
}
return max(0, Int64(seconds * 1000))
}
/// 连续手势/快进快退时使用 pending seek 的目标位置,避免仍基于旧的播放位置计算。
private func referencePositionMs() -> Int64 {
pendingSeekPositionMs ?? currentPositionMs()
}
private func preparePlaybackItem(with playbackURL: URL) {
let newItem = makePlayerItem(url: playbackURL)
activePlaybackURL = playbackURL
attachItemObservers(to: newItem)
player.replaceCurrentItem(with: newItem)
let shouldPlay = config.autoPlay || playbackIntent
if shouldPlay {
player.play()
}
playbackIntent = shouldPlay
tick()
}
private func makePlayerItem(url: URL) -> AVPlayerItem {
guard !url.isFileURL else {
return AVPlayerItem(url: url)
}
var options: [String: Any] = [:]
let ext = url.pathExtension.lowercased()
if ext.isEmpty || ["mp4", "m4v", "mov"].contains(ext) {
options["AVURLAssetOverrideMIMETypeKey"] = "video/mp4"
}
let asset = AVURLAsset(url: url, options: options.isEmpty ? nil : options)
return AVPlayerItem(asset: asset, automaticallyLoadedAssetKeys: ["playable", "tracks", "duration"])
}
/// AVPlayer 的 seek 是异步的;所有 seek 必须统一走这里,避免播放循环、
/// 用户手势和 seek 完成回调交错后把位置拉回旧句子。
private func performSeek(
to positionMs: Int64,
resumePlayback: Bool? = nil,
completion: (() -> Void)? = nil
) {
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
// 立即切换句子锚点。否则 periodic observer 仍可能以旧句子为锚点,
// 在 seek 完成前后把新位置拉回旧句开头。
lastSentenceIndex = sentence(at: targetPositionMs)?.index
let time = CMTime(seconds: Double(targetPositionMs) / 1000, preferredTimescale: 600)
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
DispatchQueue.main.async {
guard let self, self.seekGeneration == generation else {
return
}
self.pendingSeekPositionMs = nil
self.tick()
if shouldResumePlayback {
self.player.play()
}
completion?()
// seek 已真正落在目标句后,再按目标句注册新的句尾硬边界。
self.refreshSentenceBoundaryGuard(force: shouldResumePlayback)
self.tick()
}
}
tick()
}
private func durationMs(of item: AVPlayerItem) -> Int64 {
let seconds = item.duration.seconds
guard seconds.isFinite, seconds > 0 else {
return -1
}
return Int64(seconds * 1000)
}
private func bufferedPositionMs(of item: AVPlayerItem) -> Int64 {
guard let range = item.loadedTimeRanges.last?.timeRangeValue else {
return 0
}
let seconds = range.end.seconds
guard seconds.isFinite else {
return 0
}
return max(0, Int64(seconds * 1000))
}
private func currentSpeed() -> Float {
let rate = player.rate
return rate == 0 ? player.defaultRate : rate
}
private func playbackState(of item: AVPlayerItem, positionMs: Int64, durationMs: Int64) -> PlaybackState {
if item.status == .failed {
return .idle
}
switch player.timeControlStatus {
case .waitingToPlayAtSpecifiedRate:
return .buffering
case .playing:
return .ready
case .paused:
if durationMs > 0, positionMs >= durationMs {
return .ended
}
return .ready
@unknown default:
return .idle
}
}
private func sentence(at positionMs: Int64) -> SentenceBoundary? {
guard let item = mediaItem else {
return nil
}
return item.sentences.last { sentence in
positionMs >= sentence.startMs && positionMs < sentence.endMs
}
}
/// 与安卓端一致:非连续播放时到句尾回到句首;playCurrentSentenceAndStop 播完自动暂停。
private func enforceSentenceBoundary(positionMs: Int64) {
guard config.sentenceMode,
player.timeControlStatus == .playing,
let item = mediaItem,
!item.sentences.isEmpty else {
return
}
if stopAtSentenceEnd {
guard let index = stopAtSentenceEndIndex,
let sentence = item.sentences.first(where: { $0.index == index }) else {
return
}
if positionMs >= sentence.endMs {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
player.pause()
seekRaw(sentence.startMs)
}
return
}
if config.continuousPlayback {
return
}
guard let anchorIndex = lastSentenceIndex,
let anchor = item.sentences.first(where: { $0.index == anchorIndex }) else {
return
}
if positionMs >= anchor.endMs {
seekRaw(sentenceSeekPosition(anchor))
}
}
private func seekRaw(_ positionMs: Int64, completion: (() -> Void)? = nil) {
performSeek(to: positionMs, completion: completion)
}
/// AVPlayer 的 periodic observer 间隔是 250ms。仅靠它回收句尾时,
/// 最坏情况已经播进下一句约 250ms。这里参照安卓端 PlayerMessage 的做法,
/// 在当前句 endMs 精确注册 boundary observer,到点立即执行句尾动作。
private func refreshSentenceBoundaryGuard(force: Bool = false) {
cancelSentenceBoundaryGuard()
guard config.sentenceMode,
let item = mediaItem,
!item.sentences.isEmpty else {
clearForwardPlaybackEnd()
return
}
guard force || isActivelyPlaying() else {
return
}
guard let sentence = sentenceBoundaryTarget(in: item) else {
clearForwardPlaybackEnd()
return
}
let currentPosition = referencePositionMs()
if currentPosition >= sentence.endMs {
applySentenceEndAction(sentence)
return
}
let token = sentenceEndGuardToken
let endTime = CMTime(value: CMTimeValue(sentence.endMs), timescale: 1000)
// boundary observer 负责回到句首;forwardPlaybackEndTime 是解码层硬边界,
// 即使主线程回调稍晚,AVPlayer 也不会继续渲染 endMs 之后的内容。
player.currentItem?.forwardPlaybackEndTime = endTime
sentenceEndTimeObserver = player.addBoundaryTimeObserver(
forTimes: [NSValue(time: endTime)],
queue: .main
) { [weak self] in
guard let self, token == self.sentenceEndGuardToken else {
return
}
self.applySentenceEndAction(sentence)
}
}
private func cancelSentenceBoundaryGuard() {
sentenceEndGuardToken += 1
if let observer = sentenceEndTimeObserver {
player.removeTimeObserver(observer)
}
sentenceEndTimeObserver = nil
}
private func clearForwardPlaybackEnd() {
player.currentItem?.forwardPlaybackEndTime = .invalid
}
private func isActivelyPlaying() -> Bool {
player.timeControlStatus == .playing
|| player.timeControlStatus == .waitingToPlayAtSpecifiedRate
}
private func sentenceBoundaryTarget(in item: TrainingMediaItem) -> SentenceBoundary? {
if stopAtSentenceEnd {
guard let index = stopAtSentenceEndIndex else {
return nil
}
return item.sentences.first(where: { $0.index == index })
}
if config.continuousPlayback {
return nil
}
if let index = lastSentenceIndex ?? currentSentence?.index,
let sentence = item.sentences.first(where: { $0.index == index }) {
return sentence
}
let position = referencePositionMs()
if let sentence = sentence(at: position) {
return sentence
}
return item.sentences.last(where: { $0.startMs <= position })
}
private func applySentenceEndAction(_ target: SentenceBoundary) {
cancelSentenceBoundaryGuard()
guard let item = mediaItem,
let sentence = item.sentences.first(where: { $0.index == target.index }) else {
return
}
lastSentenceIndex = sentence.index
if stopAtSentenceEnd, stopAtSentenceEndIndex == sentence.index {
stopAtSentenceEnd = false
stopAtSentenceEndIndex = nil
playbackIntent = false
player.pause()
seekRaw(sentenceSeekPosition(sentence))
tick()
return
}
if config.continuousPlayback {
clearForwardPlaybackEnd()
return
}
// forwardPlaybackEndTime 到点后 AVPlayer 会自动暂停。这里先回到句首,
// 等 seek 完成后再恢复播放,实现同一句循环且不混入下一句内容。
player.pause()
seekRaw(sentenceSeekPosition(sentence)) { [weak self] in
guard let self else {
return
}
self.player.play()
self.refreshSentenceBoundaryGuard(force: true)
self.tick()
}
}
private func sentenceSeekPosition(_ sentence: SentenceBoundary) -> Int64 {
let available = max(0, sentence.endMs - sentence.startMs - 1)
let inset = min(Self.sentenceSeekInsetMs, available)
return sentence.startMs + inset
}
private func seekToSentenceBeginning(_ sentence: SentenceBoundary) {
seek(to: sentenceSeekPosition(sentence))
}
private func handleEnded() {
guard loopMode != .off else {
tick()
return
}
// 句尾守卫已经发起“回到句首”的 seek 时,这个通知只是硬边界的附带通知,
// 不能再被识别成整条视频结束。
guard pendingSeekPositionMs == nil else {
tick()
return
}
// 评测“播放原句”即使处于连续播放,也要用当前句硬边界,不能滑进下一句。
if stopAtSentenceEnd,
let index = stopAtSentenceEndIndex,
let sentence = mediaItem?.sentences.first(where: { $0.index == index }) {
applySentenceEndAction(sentence)
return
}
// 若最后一个句子的 endMs 就是视频末尾,forwardPlaybackEndTime 和自然结束
// 是同一时间点。此时必须执行“当前句”的句尾动作,而不能整条视频回零。
if config.sentenceMode,
!config.continuousPlayback,
let sentence = currentSentence,
currentPositionMs() + 30 >= sentence.endMs {
applySentenceEndAction(sentence)
return
}
// 自定义 forwardPlaybackEndTime 到达时也会发布 didPlayToEndTime。
// 这时不能当成整条视频结束回到 0;句尾守卫会马上回到当前句。
guard isNaturalMediaEnd() else {
tick()
return
}
// 播放中自然结束时 playbackIntent 仍为 true;如果用户本来已暂停,
// 则不应因为到达 media end 而突然自动循环。
performSeek(to: .zero)
tick()
}
private func isNaturalMediaEnd() -> Bool {
guard let item = player.currentItem else {
return true
}
let durationMs = durationMs(of: item)
let positionMs = currentPositionMs()
if durationMs > 0 {
return positionMs + 30 >= durationMs
}
guard let sentence = currentSentence else {
return true
}
return positionMs + 30 >= sentence.endMs
}
// MARK: - 观察器
private func attachItemObservers(to item: AVPlayerItem) {
itemStatusObserver?.invalidate()
itemStatusObserver = item.observe(\.status, options: [.new]) { [weak self] item, _ in
DispatchQueue.main.async {
guard let self = self else {
return
}
if item.status == .failed {
let message = item.error?.localizedDescription ?? "未知错误"
self.showTransientMessage("播放失败:\(message)")
} else if item.status == .readyToPlay {
self.considerFullFileFallback(for: item)
}
self.tick()
}
}
}
private func considerFullFileFallback(for item: AVPlayerItem) {
guard !didAttemptFullFileFallback,
let media = mediaItem,
!media.isLocalFile,
!(activePlaybackURL?.isFileURL ?? false) else {
return
}
let hasVisibleVideo = item.presentationSize.height >= 2 && item.presentationSize.width >= 2
if hasVisibleVideo {
return
}
didAttemptFullFileFallback = true
cacheFallbackTask?.cancel()
cacheFallbackTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 800_000_000)
guard let self, !Task.isCancelled else {
return
}
guard self.mediaItem?.id == media.id,
self.player.currentItem === item,
item.presentationSize.height < 2 else {
return
}
do {
let cachedURL = try await StreamingVideoCache.shared.prefetch(media.url)
guard self.mediaItem?.id == media.id else {
return
}
let shouldPlay = self.playbackIntent
let positionMs = self.pendingSeekPositionMs ?? self.currentPositionMs()
self.preparePlaybackItem(with: cachedURL)
self.cacheState = .ready
if positionMs > 0 {
self.performSeek(to: positionMs, resumePlayback: shouldPlay)
}
} catch is CancellationError {
return
} catch {
self.showTransientMessage("视频画面加载失败:\(error.localizedDescription)")
}
}
}
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .moviePlayback)
try? session.setActive(true)
}
}